diff --git a/.github/workflows/algorithm-catalog-compact.yml b/.github/workflows/algorithm-catalog-compact.yml new file mode 100644 index 0000000..8ac51cf --- /dev/null +++ b/.github/workflows/algorithm-catalog-compact.yml @@ -0,0 +1,42 @@ +name: Algorithm Catalog — compact request files + +# Mirrors dse-hub-compact.yml. Submissions arrive as uniquely-named files under +# algorithm-catalog/data/requests/ (that uniqueness is what makes two people submitting at the same +# time conflict-free). Once merged, this folds them into the canonical data and deletes the folded +# files, so data/requests/ stays small no matter how many activations get filed. +# +# [skip ci] on the commit stops this push from re-triggering the validate workflow. + +on: + push: + branches: [main] + paths: + - "algorithm-catalog/data/requests/**" + workflow_dispatch: + +jobs: + compact: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Apply requests and compact + run: node algorithm-catalog/scripts/compact.mjs + + - name: Commit compacted canonicals (if changed) + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add algorithm-catalog/data/ + if git diff --cached --quiet; then + echo "Nothing to commit." + else + git commit -m "chore: compact algorithm-catalog requests into canonical data [skip ci]" + git push + fi diff --git a/.github/workflows/algorithm-catalog-validate.yml b/.github/workflows/algorithm-catalog-validate.yml new file mode 100644 index 0000000..cc19754 --- /dev/null +++ b/.github/workflows/algorithm-catalog-validate.yml @@ -0,0 +1,59 @@ +name: Algorithm Catalog — validate + +# THE STANDARDS GATE. +# +# The rules live once in algorithm-catalog/src/rules.ts (which the form enforces while you type) +# and are mirrored in scripts/validate_data.py. This workflow is the third layer: it re-checks +# every committed request/event/algorithm on the PR, and runs the parity test that proves the two +# copies of the rules still agree. A submission that slips past the form still cannot land. +# +# No install step anywhere in the `validate` job — both scripts are stdlib-only Python 3. + +on: + pull_request: + paths: + - "algorithm-catalog/**" + push: + branches: [main] + paths: + - "algorithm-catalog/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Data + rules parity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Cross-checks algorithms/events/hazards/requests: hazard + product references resolve, + # thumbnails exist on disk, STAC names and dates and bboxes obey the same rules as the form. + - name: Validate committed data + run: python3 algorithm-catalog/scripts/validate_data.py + + # Runs one fixture table through BOTH rule implementations and fails if they disagree, so + # rules.ts and validate_data.py can never silently drift apart. + - name: Rules parity (rules.ts vs validate_data.py) + run: python3 algorithm-catalog/scripts/rules_parity_test.py + + build: + name: Typecheck + build + runs-on: ubuntu-latest + defaults: + run: + working-directory: algorithm-catalog + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - run: npm ci + + - run: npm run typecheck + + - run: npm run build diff --git a/.gitignore b/.gitignore index 28e5ce7..354b036 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,14 @@ leave-dashboard/dist/ !cost-dashboard/tsconfig.json !cost-dashboard/tsconfig.node.json !cost-dashboard/public/data/*.json + +# Algorithm Catalog: dashboard config + the committed catalog data (algorithms/events/hazards) +# and the submitted request files — re-included past the global *.json ignore. Unlike dse-hub +# (which relies on `git add -f`), these negations mean a plain `git add algorithm-catalog/` +# picks the data up, so a future contributor can't silently drop it from a commit. +!algorithm-catalog/package.json +!algorithm-catalog/package-lock.json +!algorithm-catalog/tsconfig.json +!algorithm-catalog/tsconfig.node.json +!algorithm-catalog/data/*.json +!algorithm-catalog/data/requests/*.json diff --git a/CLAUDE.md b/CLAUDE.md index e1567af..23932f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,20 @@ Netlify reads the `netlify.toml` **inside that base dir**, so the sites stay iso `.github/workflows/aws-pricing.yml` (weekly + dispatch; regions accumulate). A "Where these prices come from" panel hyperlinks every service to its AWS pricing page + raw price list + version. See `docs/AWS_PRICING.md`. +- `algorithm-catalog/` — Vite React SPA; `algorithm-catalog/netlify.toml` (base=`algorithm-catalog`, + build, publish `dist`). A catalog of the **NASA Disasters product algorithms** (from + `Disasters-Learning-Portal/disasters-product-algorithms@dev`). **HDS (NASA Horizon Design System) + over USWDS, light-only.** Three tabs: **📝 Submit** (the request form, default landing), + **🌀 Events** (event catalog = canonical `events.json` + submitted requests), **🛰 Algorithms** + (filter by hazard / sensor / product / modality / date / event / AOI, via two entry modes — + "start from hazard" vs "start from sensor & product" — sharing one filter state). **No + generator/Action** — data is hand-curated `data/{algorithms,events,hazards}.json` bundled at BUILD + (plus `data/requests/*.json` overlaid via `import.meta.glob`, dse-hub's merge pattern). Submitting + opens a **prefilled new-file PR** (`new/main?filename=&value=`), same as leave-dashboard/dse-hub. + **The standard is enforced automatically**: `src/rules.ts` holds the rules (the + `YYYYMM_Hazard_Location` regex is lifted verbatim from upstream `dps/_validate.sh:29-36`), + mirrored 1:1 in `scripts/validate_data.py`, kept honest by `scripts/rules_parity_test.py`, and + gated in CI by `.github/workflows/algorithm-catalog-validate.yml`. See `docs/ALGORITHM_CATALOG.md`. ## Theming (light/dark — the fte / leave / pr dashboards; dse-hub is USWDS light-only) Each dashboard themes through **CSS custom properties**: light values live in `:root`, a @@ -123,7 +137,20 @@ sets the attribute before first paint + ` + + + + + + Algorithm Catalog + + + +
+ + + diff --git a/algorithm-catalog/netlify.toml b/algorithm-catalog/netlify.toml new file mode 100644 index 0000000..5601ace --- /dev/null +++ b/algorithm-catalog/netlify.toml @@ -0,0 +1,20 @@ +# Pattern B: this dashboard is its own Netlify site, connected to the repo with +# base directory = algorithm-catalog (set in the Netlify UI). Netlify reads THIS netlify.toml. +# There is deliberately NO root-level netlify.toml — a root config's `base` would apply to EVERY +# site connected to the repo and hijack the other dashboards. Each dashboard owns its own +# /netlify.toml and its own independent Netlify site. +# +# All data is committed JSON under data/, bundled at BUILD time — no runtime fetch, no env vars, +# no secrets. Approved PRs are what update it (Netlify rebuilds on merge). +[build] + command = "npm run build" + publish = "dist" + # No `ignore` filter: always build & publish on a push so merged changes reliably go live. + # (A folder-scoped `git diff` ignore previously skipped PRODUCTION builds on the older + # dashboards whenever Netlify passed equal CACHED_COMMIT_REF/COMMIT_REF.) + +# SPA: serve index.html for any path (tabs are client-side via the URL hash). +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/algorithm-catalog/package-lock.json b/algorithm-catalog/package-lock.json new file mode 100644 index 0000000..ee1f22d --- /dev/null +++ b/algorithm-catalog/package-lock.json @@ -0,0 +1,1792 @@ +{ + "name": "algorithm-catalog", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "algorithm-catalog", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.5.4", + "vite": "^5.4.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "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", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "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", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "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" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "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", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "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" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.406", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz", + "integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "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, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "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", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "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/postcss": { + "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", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "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" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/algorithm-catalog/package.json b/algorithm-catalog/package.json new file mode 100644 index 0000000..2be149c --- /dev/null +++ b/algorithm-catalog/package.json @@ -0,0 +1,24 @@ +{ + "name": "algorithm-catalog", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Algorithm Catalog — filter the NASA Disasters product algorithms by hazard, date, activation event, sensor and product, and submit a new activation request as a prefilled PR. Data is committed JSON bundled at build time.", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.5.4", + "vite": "^5.4.9" + } +} diff --git a/algorithm-catalog/public/thumbs/CREDITS.md b/algorithm-catalog/public/thumbs/CREDITS.md new file mode 100644 index 0000000..05202e0 --- /dev/null +++ b/algorithm-catalog/public/thumbs/CREDITS.md @@ -0,0 +1,47 @@ +# Thumbnail credits + +Every image in this folder is **NASA-produced imagery**, which carries no rights restrictions +(). Each was downloaded from the source +URL below, centre-cropped to 3:2, resized to **600 × 400** and palette-quantized to keep every file +**under 150 KB** (the catalog renders them as small cards, so full resolution is wasted bytes). + +Two sources are used: + +- **NASA Worldview / NASA Earthdata GIBS snapshot API** (`wvs.earthdata.nasa.gov/api/v1/snapshot`) — + live NASA imagery services. The URLs below are reproducible: re-running one returns the same scene. +- **NASA Image and Video Library** (`images-assets.nasa.gov`, the asset host for + ) — NASA/JPL-Caltech and NASA/JSC archive imagery. + +`fallback.png` is what the UI shows for an algorithm with no thumbnail of its own. It is real +imagery, not a generated placeholder — **no file in this folder is a placeholder**; every download +succeeded. + +| File | What it actually is | Source URL | Credit | +|---|---|---|---| +| `landsat.png` | HLS L30 (Harmonized Landsat, from Landsat 8/9 OLI) true colour over Houston, TX — 2023-08-21 | `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=2023-08-21&BBOX=29.5,-95.8,30.3,-95.0&CRS=EPSG:4326&LAYERS=HLS_L30_Nadir_BRDF_Adjusted_Reflectance&FORMAT=image/jpeg&WIDTH=900&HEIGHT=600` | NASA Worldview / NASA Earthdata GIBS — HLS L30 (Landsat 8/9 OLI), NASA/USGS | +| `sentinel2.png` | HLS S30 (Harmonized Sentinel-2, from Sentinel-2 MSI) true colour over San Francisco Bay — 2023-08-12 | `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=2023-08-12&BBOX=37.5,-122.6,38.1,-121.9&CRS=EPSG:4326&LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance&FORMAT=image/jpeg&WIDTH=900&HEIGHT=600` | NASA Worldview / NASA Earthdata GIBS — HLS S30, a NASA product derived from Copernicus Sentinel-2 data (© ESA/Copernicus) | +| `satellogic.png` | Astronaut photograph of a city, Crew Earth Observations, ISS Expedition 8 (`iss008e13212`) — stands in for commercial sub-metre optical, for which there is no NASA equivalent | `https://images-assets.nasa.gov/image/iss008e13212/iss008e13212~medium.jpg` | NASA Johnson Space Center — Image Science & Analysis Laboratory, Crew Earth Observations | +| `umbra.png` | "Space Radar Image of Los Angeles, California" (`PIA01789`) — SIR-C/X-SAR aboard Space Shuttle Endeavour | `https://images-assets.nasa.gov/image/PIA01789/PIA01789~medium.jpg` | NASA/JPL-Caltech | +| `capella.png` | "Space Radar Image of West Texas — SAR Scan" (`PIA01787`), centre-cropped past the archive matting | `https://images-assets.nasa.gov/image/PIA01787/PIA01787~medium.jpg` | NASA/JPL-Caltech | +| `iceye.png` | "Space Radar Image of North Atlantic Ocean" (`PIA01799`) — SAR over water, matching ICEYE's flood/maritime use | `https://images-assets.nasa.gov/image/PIA01799/PIA01799~medium.jpg` | NASA/JPL-Caltech | +| `blackmarble.png` | VIIRS Day/Night Band at-sensor radiance, **Suomi NPP**, eastern United States — 2025-01-09 | `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=2025-01-09&BBOX=24,-100,44,-70&CRS=EPSG:4326&LAYERS=VIIRS_SNPP_DayNightBand_At_Sensor_Radiance&FORMAT=image/jpeg&WIDTH=900&HEIGHT=600` | NASA Worldview / NASA Earthdata GIBS — VIIRS DNB, Suomi NPP | +| `blackmarble_noaa.png` | VIIRS Day/Night Band at-sensor radiance, **NOAA-20**, same scene and date — the NOAA-20 counterpart | `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=2025-01-09&BBOX=24,-100,44,-70&CRS=EPSG:4326&LAYERS=VIIRS_NOAA20_DayNightBand_At_Sensor_Radiance&FORMAT=image/jpeg&WIDTH=900&HEIGHT=600` | NASA Worldview / NASA Earthdata GIBS — VIIRS DNB, NOAA-20 (JPSS-1) | +| `fallback.png` | Blue Marble shaded relief + bathymetry, Americas — the generic card for any algorithm without its own thumbnail | `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=2024-01-01&BBOX=-56,-170,72,10&CRS=EPSG:4326&LAYERS=BlueMarble_ShadedRelief_Bathymetry&FORMAT=image/jpeg&WIDTH=900&HEIGHT=600` | NASA Earth Observatory — Blue Marble: Next Generation, via NASA Worldview / GIBS | + +## Notes on honesty + +- The **SAR** thumbnails are NASA/JPL shuttle-radar imagery, not imagery from Umbra, Capella or + ICEYE — those are commercial vendors whose imagery is **not** public domain. The thumbnails + illustrate the *modality*; the `thumbCredit` field in `data/algorithms.json` is what the UI shows. +- Likewise `satellogic.png` is a NASA astronaut photograph, not Satellogic imagery, for the same + reason. +- `sentinel2.png` is a **NASA** HLS product; the underlying Sentinel-2 observation is Copernicus + data (free and open, attribution requested), which is why the credit names ESA/Copernicus too. + +## Regenerating + +Sizes and crops were produced by a one-off Pillow script (fetch → centre-crop 3:2 → resize 600×400 → +adaptive-palette PNG, stepping the palette down until the file is ≤ 150 KB). Nothing in the app or +in CI regenerates these — they are committed binaries. To replace one, drop a new 600×400 PNG here +under the same name and add a row above; `scripts/validate_data.py` fails the build if an +algorithm's `thumb` has no file on disk. diff --git a/algorithm-catalog/public/thumbs/blackmarble.png b/algorithm-catalog/public/thumbs/blackmarble.png new file mode 100644 index 0000000..456c2da Binary files /dev/null and b/algorithm-catalog/public/thumbs/blackmarble.png differ diff --git a/algorithm-catalog/public/thumbs/blackmarble_noaa.png b/algorithm-catalog/public/thumbs/blackmarble_noaa.png new file mode 100644 index 0000000..98e325a Binary files /dev/null and b/algorithm-catalog/public/thumbs/blackmarble_noaa.png differ diff --git a/algorithm-catalog/public/thumbs/capella.png b/algorithm-catalog/public/thumbs/capella.png new file mode 100644 index 0000000..3068073 Binary files /dev/null and b/algorithm-catalog/public/thumbs/capella.png differ diff --git a/algorithm-catalog/public/thumbs/fallback.png b/algorithm-catalog/public/thumbs/fallback.png new file mode 100644 index 0000000..486f943 Binary files /dev/null and b/algorithm-catalog/public/thumbs/fallback.png differ diff --git a/algorithm-catalog/public/thumbs/iceye.png b/algorithm-catalog/public/thumbs/iceye.png new file mode 100644 index 0000000..f65587a Binary files /dev/null and b/algorithm-catalog/public/thumbs/iceye.png differ diff --git a/algorithm-catalog/public/thumbs/landsat.png b/algorithm-catalog/public/thumbs/landsat.png new file mode 100644 index 0000000..714a0a3 Binary files /dev/null and b/algorithm-catalog/public/thumbs/landsat.png differ diff --git a/algorithm-catalog/public/thumbs/satellogic.png b/algorithm-catalog/public/thumbs/satellogic.png new file mode 100644 index 0000000..e524a01 Binary files /dev/null and b/algorithm-catalog/public/thumbs/satellogic.png differ diff --git a/algorithm-catalog/public/thumbs/sentinel2.png b/algorithm-catalog/public/thumbs/sentinel2.png new file mode 100644 index 0000000..630baaf Binary files /dev/null and b/algorithm-catalog/public/thumbs/sentinel2.png differ diff --git a/algorithm-catalog/public/thumbs/umbra.png b/algorithm-catalog/public/thumbs/umbra.png new file mode 100644 index 0000000..6a94fa9 Binary files /dev/null and b/algorithm-catalog/public/thumbs/umbra.png differ diff --git a/algorithm-catalog/scripts/compact.mjs b/algorithm-catalog/scripts/compact.mjs new file mode 100644 index 0000000..066d954 --- /dev/null +++ b/algorithm-catalog/scripts/compact.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// Folds every data/requests/*.json submission into the canonical data/requests.json array and +// deletes the folded files (keeps .gitkeep). +// +// Same idea as dse-hub/scripts/compact.mjs, with one difference: the Algorithm Catalog has no +// canonical array to patch — a request is a TERMINAL record, not a change doc. So the canonical +// file here IS the accumulated list of requests, created on first run if it does not exist. +// Each submission lands as its own uniquely-named file (conflict-free, token-free PRs); this +// script is what stops data/requests/ from growing without bound. +// +// Usage: node algorithm-catalog/scripts/compact.mjs (run from anywhere) +import { readdir, readFile, writeFile, unlink, stat } from "node:fs/promises"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dir, ".."); // algorithm-catalog/ + +const canonicalPath = resolve(root, "data/requests.json"); +const requestsDir = resolve(root, "data/requests"); + +async function readJson(p) { + const raw = await readFile(p, "utf8"); + return JSON.parse(raw); +} + +// ---- load the canonical array (may not exist yet — first compaction creates it) ---- +let canonical = []; +try { + const existing = await readJson(canonicalPath); + if (Array.isArray(existing)) canonical = existing; + else console.warn("data/requests.json is not an array — starting from an empty list."); +} catch { + // Missing file is the normal first-run case. +} + +// ---- load every pending submission ---- +let requestFiles; +try { + requestFiles = (await readdir(requestsDir)).filter((f) => f.endsWith(".json")).sort(); +} catch { + requestFiles = []; +} + +const incoming = []; +for (const f of requestFiles) { + const doc = await readJson(resolve(requestsDir, f)); + // A submission file holds one request; tolerate an array so several can be batched in one PR. + const docs = Array.isArray(doc) ? doc : [doc]; + for (const d of docs) { + if (d && typeof d === "object" && d.id) incoming.push(d); + else console.warn(`Skipping a record in data/requests/${f}: no "id".`); + } +} + +if (incoming.length === 0) { + console.log("No pending requests found in data/requests/. Nothing to compact."); + process.exit(0); +} + +// ---- upsert by id, newest ts wins ---- +// Oldest→newest first so that within this batch the last write is the newest; then guard against +// a stale submission clobbering a newer entry already in the canonical file. +incoming.sort((a, b) => String(a.ts ?? "").localeCompare(String(b.ts ?? ""))); + +const byId = new Map(canonical.filter((r) => r && r.id).map((r) => [r.id, r])); +let added = 0; +let updated = 0; +let skipped = 0; +for (const r of incoming) { + const prev = byId.get(r.id); + if (!prev) { + byId.set(r.id, r); + added++; + } else if (String(r.ts ?? "") >= String(prev.ts ?? "")) { + byId.set(r.id, r); + updated++; + } else { + skipped++; // canonical entry is newer — keep it + } +} + +// Chronological order keeps the committed diff readable (new requests append at the end). +const merged = [...byId.values()].sort( + (a, b) => String(a.ts ?? "").localeCompare(String(b.ts ?? "")) || String(a.id).localeCompare(String(b.id)), +); + +await writeFile(canonicalPath, JSON.stringify(merged, null, 2) + "\n", "utf8"); +console.log(`Wrote data/requests.json (${merged.length} request(s): ${added} added, ${updated} updated, ${skipped} stale ignored)`); + +// ---- delete the folded submission files (keep .gitkeep) ---- +let deleted = 0; +for (const f of requestFiles) { + await unlink(resolve(requestsDir, f)); + deleted++; +} +console.log(`Deleted ${deleted} submission file(s) from data/requests/`); + +// Recreate .gitkeep in case it was among the deleted files (it shouldn't be, but safety). +try { + await stat(resolve(requestsDir, ".gitkeep")); +} catch { + await writeFile(resolve(requestsDir, ".gitkeep"), "", "utf8"); +} + +console.log(`\nCompacted ${incoming.length} submission(s) into data/requests.json.`); diff --git a/algorithm-catalog/scripts/rules_parity_test.py b/algorithm-catalog/scripts/rules_parity_test.py new file mode 100644 index 0000000..056f7b6 --- /dev/null +++ b/algorithm-catalog/scripts/rules_parity_test.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Prove the TypeScript and Python copies of THE STANDARD agree, so they cannot silently drift. + +The standard lives twice: `src/rules.ts` (runs in the browser while a responder types) and +`scripts/validate_data.py` (runs in CI over what got committed). Two copies of a rule is two +chances to be wrong, so this test pins them together. + +Three checks, in order of how loudly they fail: + +1. **Constant parity (text).** Parse the `export const` lines out of `src/rules.ts` and assert the + pattern/placeholder/threshold text is character-identical to the Python constants (after + stripping the JS `/.../` delimiters). No Node, no TypeScript toolchain, no transpile step — + deterministic and CI-safe. +2. **Inline regex parity.** The rules also use regexes that are not exported constants + (`^[0-9]{6}$`, the bbox number/split patterns). Assert each Python pattern still appears + verbatim as a JS literal in `src/rules.ts`. +3. **Behavioral fixtures.** ~40 (value, expected pass/fail, expected rule) cases run through the + Python implementation. These also DOCUMENT the standard's real behavior, including the parts + that surprise people (see the notes on `202501_Tropical_Cyclone_CA` and `2025-02-30`). + +stdlib only. Run: python3 algorithm-catalog/scripts/rules_parity_test.py +Exit 0 pass / 1 fail. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# Import the Python copy of the standard from next door, without leaving a __pycache__ behind. +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from validate_data import ( # noqa: E402 + BBOX_MIN_LAT_SPAN, + ISO_DATE_RE, + MGRS_TILE_RE, + STAC_EVENT_RE, + STAC_EXAMPLE, + STAC_PART_COUNT, + STAC_PLACEHOLDER, + WRS2_TILE_RE, + _BBOX_NUMBER, + _BBOX_SPLIT, + _MONTH, + _YEARMONTH, + ERROR, + check_bbox, + check_dates, + check_hazards, + check_stac_name, + parse_stac_name, +) + +HERE = Path(__file__).resolve().parent +RULES_TS = HERE.parent / "src" / "rules.ts" + +failures: list[str] = [] + + +def fail(msg: str) -> None: + failures.append(msg) + print(f" FAIL {msg}") + + +# ================================================================================================= +# 1. Constant parity — src/rules.ts `export const` text vs the Python constants +# ================================================================================================= + +# `export const NAME = ;` where is a /regex/, a "string", or a number. +_EXPORT = re.compile( + r'^export const (?P[A-Z_][A-Z0-9_]*) = (?:/(?P.*)/|"(?P.*)"|(?P[0-9.]+));\s*$', + re.MULTILINE, +) + +# Python side, as SOURCE TEXT — this is what must survive a change to either file. +PY_CONSTANTS = { + "STAC_EVENT_RE": STAC_EVENT_RE, + "STAC_PART_COUNT": repr(STAC_PART_COUNT), # "3" — compare as text, not int + "STAC_PLACEHOLDER": STAC_PLACEHOLDER, + "ISO_DATE_RE": ISO_DATE_RE, + "BBOX_MIN_LAT_SPAN": repr(BBOX_MIN_LAT_SPAN), # "0.05" — compare as text, not float + "STAC_EXAMPLE": STAC_EXAMPLE, + "MGRS_TILE_RE": MGRS_TILE_RE, + "WRS2_TILE_RE": WRS2_TILE_RE, +} + + +def check_constants() -> None: + print("1. constant parity (src/rules.ts <-> validate_data.py)") + if not RULES_TS.is_file(): + fail(f"src/rules.ts not found at {RULES_TS}") + return + + source = RULES_TS.read_text(encoding="utf-8") + ts: dict[str, str] = {} + for m in _EXPORT.finditer(source): + ts[m.group("name")] = m.group("re") or m.group("str") or m.group("num") or "" + + for name, py in PY_CONSTANTS.items(): + if name not in ts: + fail(f"{name}: not exported from src/rules.ts (looked for `export const {name} = ...;`)") + continue + if ts[name] != py: + fail( + f"{name} DIFFERS\n" + f" src/rules.ts : {ts[name]!r}\n" + f" validate_data.py: {py!r}" + ) + else: + print(f" ok {name} = {py!r}") + + extra = sorted(set(ts) - set(PY_CONSTANTS)) + if extra: + # A new exported constant means a new rule the Python mirror probably has not learned. + fail(f"src/rules.ts exports constants the Python mirror does not know about: {', '.join(extra)}") + + +# ================================================================================================= +# 2. Inline regex parity — patterns used inside the rule bodies, not exported +# ================================================================================================= + +INLINE = { + "yearmonth (checkStacName)": _YEARMONTH.pattern, + "month (checkStacName)": _MONTH.pattern, + "bbox number (checkBbox)": _BBOX_NUMBER.pattern, + "bbox split (checkBbox)": _BBOX_SPLIT.pattern, +} + + +def check_inline_regexes() -> None: + print("\n2. inline regex parity") + if not RULES_TS.is_file(): + return + source = RULES_TS.read_text(encoding="utf-8") + for label, pattern in INLINE.items(): + literal = f"/{pattern}/" + if literal in source: + print(f" ok {label}: {literal}") + else: + fail(f"{label}: {literal} no longer appears in src/rules.ts") + + +# ================================================================================================= +# 3. Behavioral fixtures +# ================================================================================================= + +# A tiny stand-in for data/hazards.json, in the same shape as the real file: the hazard `id` IS the +# CamelCase token, so a request must submit "Fire", never "fire". +VOCAB = [ + {"id": "Fire", "label": "Fire", "token": "Fire", "aliases": ["Wildfire", "Wildfires", "Burn"]}, + {"id": "Flood", "label": "Flood", "token": "Flood", "aliases": ["Flooding", "Inundation"]}, + {"id": "Earthquake", "label": "Earthquake", "token": "Earthquake", "aliases": ["Quake", "Seismic"]}, + { + "id": "TropicalCyclone", + "label": "Tropical Cyclone", + "token": "TropicalCyclone", + "aliases": ["Hurricane", "Typhoon", "TropicalStorm", "Cyclone"], + }, +] + +# (kind, value, should_pass, expected first error rule or None, note) +FIXTURES: list[tuple[str, object, bool, str | None, str]] = [ + # --- STAC event names: the ones that must pass ------------------------------------------------- + ("stac", "202501_Fire_CA", True, None, "the canonical shape"), + ("stac", "202511_Flood_TX", True, None, "the documented example"), + ("stac", "202606_Earthquake_Venezuela", True, None, "long location"), + ("stac", "202604_Typhoon_Sinlaku", True, None, "storm name as location"), + ("stac", "202604_TropicalCyclone_Guam", True, None, "CamelCase multi-word hazard — the prescribed fix"), + ("stac", "202112_Volcano_Iceland", True, None, "past year"), + ("stac", "202501_fire_CA", True, None, "DOCUMENTED: the regex is case-blind; lowercase passes"), + # --- STAC event names: the ones that must fail ------------------------------------------------- + # Exactly 2 underscores. Both directions are hard errors — this is the STRICTER-THAN-UPSTREAM + # rule, and the reason the app exists. See the STAC_EVENT_RE docblock in src/rules.ts. + ( + "stac", + "202501_Tropical_Cyclone_CA", + False, + "underscore-count", + "3 underscores. Upstream ACCEPTS this and silently reads hazard='Tropical', " + "location='Cyclone_CA'; we block it. Write 202501_TropicalCyclone_CA.", + ), + ( + "stac", + "202501_Flood_CA_extra", + False, + "underscore-count", + "3 underscores. An EXPLICIT upstream pass case " + "(tests/integration/test_dps_validate.sh:41-48) that we knowingly refuse — the location " + "slot may not absorb underscores here.", + ), + ("stac", "202512_Hurricane_Gulf_of_Mexico", False, "underscore-count", "5 underscores; use GulfOfMexico"), + ("stac", "202501_Fire", False, "underscore-count", "1 underscore, no location"), + ("stac", "202501-Fire-CA", False, "underscore-count", "0 underscores; hyphens are not separators"), + ("stac", "202513_Fire_CA", False, "month", "month 13"), + ("stac", "202500_Fire_CA", False, "month", "month 00"), + ("stac", "YYYYMM_Hazard_Location", False, "placeholder", "the upstream placeholder"), + ("stac", "2025_Fire_CA", False, "yearmonth", "4-digit year, no month"), + ("stac", "20250_Fire_CA", False, "yearmonth", "5 digits"), + ("stac", "2025011_Fire_CA", False, "yearmonth", "7 digits"), + ("stac", "202501_Fire_", False, "format", "right underscore count, but empty location"), + ("stac", "202501__CA", False, "format", "right underscore count, but empty hazard"), + ("stac", "", False, "required", "empty"), + ("stac", " ", False, "required", "whitespace only"), + # --- ISO dates --------------------------------------------------------------------------------- + ("iso-date", "2025-01-15", True, None, "valid"), + ("iso-date", "2025-12-31", True, None, "valid end of year"), + ( + "iso-date", + "2025-02-30", + True, + None, + "DOCUMENTED: PASSES — upstream checks SHAPE only, there is no calendar check", + ), + ("iso-date", "2025-13-01", False, "format", "month 13"), + ("iso-date", "2025-00-10", False, "format", "month 00"), + ("iso-date", "2025-01-32", False, "format", "day 32"), + ("iso-date", "2025-1-5", False, "format", "unpadded"), + ("iso-date", "20250115", False, "format", "no separators"), + ("iso-date", "", False, "required", "empty"), + # --- date ordering ----------------------------------------------------------------------------- + ("date-order", ("2025-01-07", "2025-01-31"), True, None, "start before end"), + ("date-order", ("2025-01-07", "2025-01-07"), True, None, "single-day event"), + ("date-order", ("2025-01-31", "2025-01-07"), False, "order", "end before start"), + # --- bbox -------------------------------------------------------------------------------------- + ("bbox", "", True, None, "bbox is optional"), + ("bbox", "-120,34,-118,36", True, None, "valid comma-separated"), + ("bbox", "-120 34 -118 36", True, None, "whitespace-separated is accepted"), + ("bbox", "-118.9,33.6,-117.6,34.4", True, None, "decimals"), + ("bbox", "-120,34,-118", False, "count", "3 numbers"), + ("bbox", "-120,34,-118,abc", False, "numeric", "not a number"), + ("bbox", "-200,34,-118,36", False, "range", "lon < -180"), + ("bbox", "-120,34,-118,95", False, "range", "lat > 90"), + ("bbox", "-118,34,-120,36", False, "order", "min_lon >= max_lon"), + ("bbox", "-120,34.00,-118,34.02", False, "lat-span", f"lat span < {BBOX_MIN_LAT_SPAN} (Black Marble)"), + # --- hazard vocabulary -------------------------------------------------------------------------- + ("hazards", ["Fire"], True, None, "known id"), + ("hazards", ["Fire", "Flood"], True, None, "two known ids"), + ("hazards", ["TropicalCyclone"], True, None, "CamelCase multi-word id"), + ("hazards", [], False, "required", "none selected"), + ("hazards", ["fire"], False, "vocabulary", "ids are case-sensitive — the id is 'Fire'"), + ("hazards", ["Wildfire"], False, "vocabulary", "an ALIAS is not an id; Wildfire resolves to Fire"), + ("hazards", ["Tropical_Cyclone"], False, "vocabulary", "unknown AND contains an underscore"), +] + +# The soft hazard cross-check: the regex accepts ANY token in slot 2, so the controlled vocabulary +# is the only thing that can catch a semantically wrong (but structurally legal) name. These cases +# must produce NO error and exactly the listed warning rules. +# (value, selected hazard ids, expected warning rules, note) +# NOTE: 202501_Tropical_Cyclone_CA and 202501_Flood_CA_extra used to live here as WARNING cases. +# They are now hard `underscore-count` ERRORS (see FIXTURES) and never reach this cross-check. +WARNING_FIXTURES: list[tuple[str, list[str], list[str], str]] = [ + ( + "202604_TropicalCyclone_Guam", + ["TropicalCyclone"], + [], + "the CamelCase rewrite of the name the standard now blocks — clean, no warning", + ), + ("202501_Wildfire_CA", ["Fire"], [], "alias: Wildfire resolves to the Fire hazard"), + ("202604_Typhoon_Sinlaku", ["TropicalCyclone"], [], "alias: Typhoon resolves to TropicalCyclone"), + ("202501_Quake_Venezuela", ["Earthquake"], [], "alias: Quake resolves to the Earthquake hazard"), + ("202501_Fire_CA", ["Flood"], ["hazard-mismatch"], "known token, but that hazard is not selected"), + ("202501_Sinkhole_FL", ["Fire"], ["hazard-vocabulary"], "token outside the vocabulary"), +] + +# parse_stac_name() slotting — the behavior every "why did that pass?" question comes back to. +# It returns None for anything that is not exactly 3 parts — it no longer joins the tail into the +# location slot, which is precisely the upstream behavior the tightened standard exists to prevent. +PARSE_CASES: list[tuple[str, tuple[str, str, str] | None]] = [ + ("202501_Fire_CA", ("202501", "Fire", "CA")), + ("202604_TropicalCyclone_Guam", ("202604", "TropicalCyclone", "Guam")), + ("202501_Flood_CA_extra", None), + ("202501_Tropical_Cyclone_CA", None), + ("202501_Fire", None), +] + + +def run_fixture(kind: str, value: object) -> list: + if kind == "stac": + # No vocabulary passed: this fixture table tests the STANDARD, not the soft hazard warnings. + return check_stac_name(str(value)) + if kind == "iso-date": + # start == end so only the shape rule can fire. + return check_dates(str(value), str(value)) + if kind == "date-order": + start, end = value # type: ignore[misc] + return check_dates(start, end) + if kind == "bbox": + return check_bbox(str(value)) + if kind == "hazards": + return check_hazards(list(value), VOCAB) # type: ignore[arg-type] + raise AssertionError(f"unknown fixture kind {kind!r}") + + +def check_fixtures() -> None: + print(f"\n3. behavioral fixtures ({len(FIXTURES)} cases) through the Python rules") + for kind, value, should_pass, expected_rule, note in FIXTURES: + violations = run_fixture(kind, value) + errors = [v for v in violations if v.severity == ERROR] + passed = not errors + if passed != should_pass: + fail( + f"[{kind}] {value!r}: expected {'PASS' if should_pass else 'FAIL'}, " + f"got {'PASS' if passed else 'FAIL'} ({note})\n" + f" violations: {[(v.rule, v.message) for v in errors] or 'none'}" + ) + continue + if expected_rule is not None and errors[0].rule != expected_rule: + fail( + f"[{kind}] {value!r}: expected rule {expected_rule!r}, got {errors[0].rule!r} ({note})\n" + f" message: {errors[0].message}" + ) + continue + verdict = "pass" if should_pass else f"fail[{expected_rule}]" + print(f" ok [{kind}] {value!r} -> {verdict}") + + print("\n hazard vocabulary cross-check (errors vs warnings)") + for value, selected, expected_warnings, note in WARNING_FIXTURES: + violations = check_stac_name(value, VOCAB, selected) + errors = [v.rule for v in violations if v.severity == ERROR] + warnings = [v.rule for v in violations if v.severity != ERROR] + if errors: + fail(f"[warn] {value!r}: expected NO error, got {errors} ({note})") + elif warnings != expected_warnings: + fail(f"[warn] {value!r}: expected warnings {expected_warnings}, got {warnings} ({note})") + else: + print(f" ok [warn] {value!r} + {selected} -> warnings {warnings or '[]'}") + + print("\n parse_stac_name() slotting") + for value, expected in PARSE_CASES: + got = parse_stac_name(value) + got_tuple = None if got is None else (got["yearMonth"], got["hazard"], got["location"]) + if got_tuple != expected: + fail(f"parse_stac_name({value!r}): expected {expected!r}, got {got_tuple!r}") + else: + print(f" ok parse_stac_name({value!r}) -> {got_tuple!r}") + + +def main() -> int: + print(f"THE STANDARD parity test — {RULES_TS}\n") + check_constants() + check_inline_regexes() + check_fixtures() + + print() + if failures: + print(f"FAILED — {len(failures)} parity/behavior mismatch(es):") + for f in failures: + print(f" - {f.splitlines()[0]}") + print("\nThe TS and Python copies of THE STANDARD have drifted. Fix src/rules.ts AND") + print("scripts/validate_data.py together, then re-run this test.") + return 1 + print("OK — src/rules.ts and scripts/validate_data.py encode the same standard.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/algorithm-catalog/scripts/validate_data.py b/algorithm-catalog/scripts/validate_data.py new file mode 100644 index 0000000..52c55fa --- /dev/null +++ b/algorithm-catalog/scripts/validate_data.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python3 +"""Validate the Algorithm Catalog data against THE STANDARD. + +Two jobs in one script: + +1. **Mirror `src/rules.ts` 1:1** — the naming standard (`YYYYMM_Hazard_Location`), ISO date shape, + bbox rules, hazard vocabulary. The TS copy runs in the browser while a responder types; this + copy runs in CI over what actually got committed. `scripts/rules_parity_test.py` proves the two + copies still agree, so they cannot silently drift. +2. **Structural + referential checks the browser cannot do** — duplicate ids, dangling + algorithm/product/hazard references, missing thumbnail files, unparseable JSON. + +stdlib only (no pip deps) — house rule: this runs in CI with no install step. + +Run: + python3 algorithm-catalog/scripts/validate_data.py + python3 algorithm-catalog/scripts/validate_data.py --json + python3 algorithm-catalog/scripts/validate_data.py --quiet + +Exit 0 when clean (warnings are allowed), 1 when any ERROR is found. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +# Anchor every path off this file, never off the cwd — CI, Netlify and humans all run it from +# somewhere different. +ROOT = Path(__file__).resolve().parent.parent # algorithm-catalog/ +DATA = ROOT / "data" +PUBLIC = ROOT / "public" + +# ================================================================================================= +# THE STANDARD — mirrored 1:1 from src/rules.ts. +# +# The pattern SOURCE TEXT below is kept character-identical to the JavaScript literals so that +# rules_parity_test.py can compare the two files' constants literally (no JS engine needed). +# If you edit a rule here you MUST edit src/rules.ts too; the parity test fails otherwise. +# ================================================================================================= + +#: YYYYMM_Hazard_Location — EXACTLY three parts, so exactly two underscores. +#: +#: DELIBERATELY STRICTER THAN UPSTREAM. dps/_validate.sh:29-36 uses +#: ^[0-9]{4}(0[1-9]|1[0-2])_[^_]+_.+$ +#: whose final `.+` lets the LOCATION slot swallow extra underscores. That accepts +#: `202501_Tropical_Cyclone_CA` — which looks right but silently parses as hazard=`Tropical`, +#: location=`Cyclone_CA`, and then writes a GeoTIFF `HAZARD` tag of "Tropical". It also accepts +#: `202501_Flood_CA_extra` (an explicit upstream pass case, see +#: tests/integration/test_dps_validate.sh:41-48). +#: +#: The catalog rejects both: the third slot is `[^_]+` too. This is the safe direction to diverge — +#: every name WE accept, DPS also accepts. We only refuse some names DPS would have taken, and +#: those are precisely the ones that mis-slot the hazard. Multi-word hazards and locations must be +#: CamelCase: 202604_TropicalCyclone_Guam, not 202604_Tropical_Cyclone_Guam. +STAC_EVENT_RE = r'^[0-9]{4}(0[1-9]|1[0-2])_[^_]+_[^_]+$' + +#: Exactly three underscore-separated slots: YYYYMM, Hazard, Location. +STAC_PART_COUNT = 3 + +#: The literal placeholder the upstream configs ship with. Rejected at run time. +STAC_PLACEHOLDER = "YYYYMM_Hazard_Location" + +#: dps/_validate.sh:61-76 — validate_date_not_before() date shape. +ISO_DATE_RE = r'^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$' + +#: dps/_validate.sh:118-143 — validate_bbox(). blackmarble.crs rejects a thinner box. +BBOX_MIN_LAT_SPAN = 0.05 + +#: A worked example to show the user in every error message about the event name. +STAC_EXAMPLE = "202511_Flood_TX" + +#: Optional scene selectors. Sentinel-2 MGRS tile (sentinel2's `tile` input), e.g. T17RLN. +#: Landsat WRS-2 path/row (landsat's `process_tile` input), e.g. 171035. Only a non-empty, +#: malformed value is an error — both fields are optional. +MGRS_TILE_RE = r'^T[0-9]{2}[A-Z]{3}$' +WRS2_TILE_RE = r'^[0-9]{6}$' + +_STAC_EVENT = re.compile(STAC_EVENT_RE) +_ISO_DATE = re.compile(ISO_DATE_RE) +_YEARMONTH = re.compile(r'^[0-9]{6}$') +_MONTH = re.compile(r'^(0[1-9]|1[0-2])$') +_BBOX_NUMBER = re.compile(r'^[-+]?[0-9]*\.?[0-9]+$') +_BBOX_SPLIT = re.compile(r'[,\s]+') + + +def matches(rx: re.Pattern, v: str) -> bool: + """`RegExp.test()` semantics for an anchored pattern. + + WHY: Python's `$` also matches just *before* a trailing newline, JavaScript's (without /m) + does not. Rejecting any value that contains a newline makes the two engines agree on every + pattern in this file, without having to alter the shared pattern text. + """ + return "\n" not in v and rx.search(v) is not None + + +# ------------------------------------------------------------------------------------------------- +# Violation type (mirrors the TS `Violation` interface) +# ------------------------------------------------------------------------------------------------- + +ERROR = "error" +WARNING = "warning" + + +@dataclass(frozen=True) +class Violation: + field: str + rule: str + severity: str + message: str + + +def _err(field: str, rule: str, message: str) -> Violation: + return Violation(field, rule, ERROR, message) + + +def _warn(field: str, rule: str, message: str) -> Violation: + return Violation(field, rule, WARNING, message) + + +# ------------------------------------------------------------------------------------------------- +# Individual rules (mirrors of the exported TS functions, same names in snake_case) +# ------------------------------------------------------------------------------------------------- + + +def parse_stac_name(v: str) -> dict[str, str] | None: + """Split a STAC event name into its three slots, or None if it isn't exactly three. + + Note this does NOT mirror upstream cog_metadata.parse_activation_event(), which does a + `split('_', 2)` so LOCATION absorbs every trailing underscore. That absorption is the bug we + are closing — see STAC_EVENT_RE. + """ + parts = v.split("_") + if len(parts) != STAC_PART_COUNT: + return None + return {"yearMonth": parts[0], "hazard": parts[1], "location": parts[2]} + + +def check_stac_name( + value: str, + hazards: list[dict[str, Any]] | None = None, + selected: list[str] | None = None, +) -> list[Violation]: + """The headline check: STAC metadata event name must be YYYYMM_Hazard_Location. + + `hazards` (optional) enables the soft cross-check that the token matches the hazards selected + on the form — the regex permits any token, but a mismatch is nearly always a mistake. + """ + hazards = hazards or [] + selected = selected or [] + v = value.strip() + out: list[Violation] = [] + + if not v: + out.append(_err("stacName", "required", "STAC metadata event name is required.")) + return out + if v == STAC_PLACEHOLDER: + out.append( + _err( + "stacName", + "placeholder", + f'"{STAC_PLACEHOLDER}" is the placeholder value and is rejected at run time. ' + f"Set a real event, e.g. {STAC_EXAMPLE}.", + ) + ) + return out + if not matches(_STAC_EVENT, v): + # Say precisely which part is wrong — a bare "doesn't match" is useless to a responder. + # Underscore count is checked FIRST and reported on its own, because it is the mistake + # people actually make (writing a multi-word hazard or location in full). + parts = v.split("_") + underscores = len(parts) - 1 + + if underscores < 2: + out.append( + _err( + "stacName", + "underscore-count", + f"Needs exactly 2 underscores (YYYYMM_Hazard_Location) — found {underscores}. " + f"Example: {STAC_EXAMPLE}.", + ) + ) + elif underscores > 2: + # The dangerous one. Show them what it would silently be read as. + location = "_".join(parts[2:]) + out.append( + _err( + "stacName", + "underscore-count", + f"Needs exactly 2 underscores (YYYYMM_Hazard_Location) — found {underscores}. " + f'"{v}" would be read as hazard "{parts[1]}", location "{location}". ' + f"Run multi-word names together in CamelCase, e.g. {STAC_EXAMPLE} or " + f"202604_TropicalCyclone_Guam.", + ) + ) + elif not matches(_YEARMONTH, parts[0]): + out.append(_err("stacName", "yearmonth", f'"{parts[0]}" must be a 6-digit YYYYMM, e.g. 202511.')) + elif not matches(_MONTH, parts[0][4:]): + out.append( + _err("stacName", "month", f'Month "{parts[0][4:]}" is not 01-12. Example: {STAC_EXAMPLE}.') + ) + else: + out.append(_err("stacName", "format", f"Must be YYYYMM_Hazard_Location, e.g. {STAC_EXAMPLE}.")) + return out + + parsed = parse_stac_name(v) + assert parsed is not None # structurally guaranteed past the regex + hazard = parsed["hazard"] + + if hazards: + low = hazard.lower() + + def _is(h: dict[str, Any]) -> bool: + token = h.get("token") if isinstance(h.get("token"), str) else "" + aliases = h.get("aliases") if isinstance(h.get("aliases"), list) else [] + return token.lower() == low or any(isinstance(a, str) and a.lower() == low for a in aliases) + + match = next((h for h in hazards if _is(h)), None) + if match is None: + known = ", ".join(str(h.get("token", "")) for h in hazards) + out.append( + _warn( + "stacName", + "hazard-vocabulary", + f'Hazard token "{hazard}" is not in the controlled vocabulary. Known tokens: {known}.', + ) + ) + elif selected and match.get("id") not in selected: + out.append( + _warn( + "stacName", + "hazard-mismatch", + f'Event name says "{hazard}" but that hazard is not selected below.', + ) + ) + + return out + + +def check_dates(start: str, end: str) -> list[Violation]: + """ISO shape + ordering. Upstream only checks shape; start<=end is our own catalog invariant.""" + out: list[Violation] = [] + if not start: + out.append(_err("start", "required", "Start date is required.")) + elif not matches(_ISO_DATE, start): + out.append(_err("start", "format", f'Start date "{start}" must be YYYY-MM-DD.')) + + if not end: + out.append(_err("end", "required", "End date is required.")) + elif not matches(_ISO_DATE, end): + out.append(_err("end", "format", f'End date "{end}" must be YYYY-MM-DD.')) + + if start and end and matches(_ISO_DATE, start) and matches(_ISO_DATE, end) and start > end: + out.append(_err("end", "order", f"End date {end} is before start date {start}.")) + return out + + +def check_bbox(value: str) -> list[Violation]: + """dps/_validate.sh:118-143 — validate_bbox(), rule for rule. Empty is allowed (bbox is optional).""" + v = value.strip() + if not v: + return [] + parts = [p for p in _BBOX_SPLIT.split(v) if p] + if len(parts) != 4: + return [ + _err( + "bbox", + "count", + f'Bounding box needs exactly 4 numbers "min_lon,min_lat,max_lon,max_lat"; got {len(parts)}.', + ) + ] + if not all(matches(_BBOX_NUMBER, p) for p in parts): + return [ + _err( + "bbox", + "numeric", + 'Bounding box must be four numbers "min_lon,min_lat,max_lon,max_lat" (WGS84).', + ) + ] + min_lon, min_lat, max_lon, max_lat = (float(p) for p in parts) + out: list[Violation] = [] + if min_lon < -180 or max_lon > 180 or min_lat < -90 or max_lat > 90: + out.append(_err("bbox", "range", "Bounding box is out of range (lon in [-180,180], lat in [-90,90]).")) + return out + if min_lon >= max_lon or min_lat >= max_lat: + out.append(_err("bbox", "order", "Bounding box needs min_lon list[Violation]: + out: list[Violation] = [] + if not selected: + out.append(_err("hazards", "required", "Select at least one hazard type.")) + return out + ids = {h.get("id") for h in vocabulary} + for h in selected: + if h not in ids: + out.append(_err("hazards", "vocabulary", f'"{h}" is not a known hazard type.')) + if "_" in h: + out.append(_err("hazards", "underscore", f'Hazard "{h}" must not contain an underscore.')) + return out + + +def check_locations(locations: list[str]) -> list[Violation]: + clean = [l.strip() for l in locations if l.strip()] + if not clean: + return [_err("locations", "required", "Add at least one location, e.g. Los Angeles, CA.")] + return [] + + +def check_name(name: str) -> list[Violation]: + if not name.strip(): + return [ + _err( + "name", + "required", + "Activation event name is required — this is the human-readable name shown on the " + "website, e.g. California Wildfires January 2025.", + ) + ] + return [] + + +def check_products(products: list[dict[str, str]]) -> list[Violation]: + if not products: + return [_err("products", "required", "Select at least one product to generate.")] + return [] + + +def validate_request(r: dict[str, Any], vocabulary: list[dict[str, Any]]) -> list[Violation]: + """Whole-request validation — the Python side of what gates the "Open PR" button.""" + return [ + *check_name(_s(r, "name")), + *check_stac_name(_s(r, "stacName"), vocabulary, _strs(r, "hazards")), + *check_dates(_s(r, "start"), _s(r, "end")), + *check_hazards(_strs(r, "hazards"), vocabulary), + *check_locations(_strs(r, "locations")), + *check_bbox(_s(r, "bbox")), + *check_products(_dicts(r, "products")), + ] + + +# ================================================================================================= +# Catalog validation — structure, references, files on disk +# ================================================================================================= + +# An Algorithm's `products` and a request's `products` are different shapes that share a field name, +# so the algorithm-side checks pass this example explicitly instead of using the table below. +ALGORITHM_PRODUCT_EXAMPLE = ( + '"products": [{"id": "swir", "label": "Shortwave Infrared", "folder": "shortwaveInfrared", "hazards": ["fire"]}]' +) + +# A "valid example" to show next to every violation, keyed "." then "". +RULE_EXAMPLES = { + "stacName": STAC_EXAMPLE, + "id.event-id-mismatch": f'"id": "{STAC_EXAMPLE}" (id must equal stacName)', + "id.duplicate": '"id": "sentinel2" (used exactly once)', + "id": '"id": "sentinel2"', + "token": '"token": "TropicalCyclone" (CamelCase, no underscore)', + "name": '"name": "California Wildfires January 2025"', + "start": '"start": "2025-01-07"', + "end": '"end": "2025-01-31"', + "bbox": '"bbox": "-118.9,33.6,-117.6,34.4"', + "hazards": '"hazards": ["fire", "flood"]', + "locations": '"locations": ["Los Angeles, CA"]', + "products": '"products": [{"algorithm": "sentinel2", "product": "swir"}]', + "layers": '"layers": [{"algorithm": "sentinel2", "product": "swir"}]', + "thumb": '"thumb": "/thumbs/sentinel2.png" (file at public/thumbs/sentinel2.png)', + "temporalStart": '"temporalStart": "2015-06-23"', + "kind": '"kind": "algorithm-request"', + "ts": '"ts": "2025-01-08T17:04:11.284Z"', + "": "a JSON array of records, e.g. []", +} + + +def example_for(field: str, rule: str) -> str: + return RULE_EXAMPLES.get(f"{field}.{rule}") or RULE_EXAMPLES.get(field) or "" + + +@dataclass +class Finding: + file: str # path relative to algorithm-catalog/ + where: str # which record inside that file + field: str + rule: str + severity: str + message: str + example: str + + def as_dict(self) -> dict[str, str]: + return { + "file": self.file, + "where": self.where, + "field": self.field, + "rule": self.rule, + "severity": self.severity, + "message": self.message, + "example": self.example, + } + + +class Report: + def __init__(self) -> None: + self.findings: list[Finding] = [] + + def add(self, file: str, where: str, v: Violation, example: str = "") -> None: + self.findings.append( + Finding(file, where, v.field, v.rule, v.severity, v.message, example or example_for(v.field, v.rule)) + ) + + def error(self, file: str, where: str, field: str, rule: str, message: str, example: str = "") -> None: + self.add(file, where, _err(field, rule, message), example) + + def warn(self, file: str, where: str, field: str, rule: str, message: str, example: str = "") -> None: + self.add(file, where, _warn(field, rule, message), example) + + @property + def errors(self) -> list[Finding]: + return [f for f in self.findings if f.severity == ERROR] + + @property + def warnings(self) -> list[Finding]: + return [f for f in self.findings if f.severity == WARNING] + + +# --- tolerant field readers ----------------------------------------------------------------------- +# WHY: a value of the wrong JSON type degrades to empty here so the ordinary required/format rule +# fires with a message that names the field, instead of the script dying on a TypeError. + + +def _s(rec: dict[str, Any], key: str) -> str: + v = rec.get(key) + return v if isinstance(v, str) else "" + + +def _strs(rec: dict[str, Any], key: str) -> list[str]: + v = rec.get(key) + return [x for x in v if isinstance(x, str)] if isinstance(v, list) else [] + + +def _dicts(rec: dict[str, Any], key: str) -> list[dict[str, Any]]: + v = rec.get(key) + return [x for x in v if isinstance(x, dict)] if isinstance(v, list) else [] + + +def _obj(rec: dict[str, Any], key: str) -> dict[str, Any]: + v = rec.get(key) + return v if isinstance(v, dict) else {} + + +def rel(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return str(path) + + +# --- loading -------------------------------------------------------------------------------------- + + +def scan_data_dir(rep: Report) -> dict[str, Any]: + """Parse every *.json under data/. Returns {relative path: parsed}; unparseable files are + reported and omitted.""" + parsed: dict[str, Any] = {} + if not DATA.is_dir(): + rep.error("data/", "-", "", "missing-dir", f"No data directory at {DATA}.", "algorithm-catalog/data/") + return parsed + for path in sorted(DATA.rglob("*")): + if path.is_dir(): + continue + name = rel(path) + if path.suffix != ".json": + if path.name != ".gitkeep": + rep.warn(name, "-", "", "not-json", "Unexpected non-JSON file under data/.", "*.json") + continue + try: + parsed[name] = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + rep.error(name, "-", "", "json-parse", f"Invalid JSON: {e.msg} (line {e.lineno}, column {e.colno}).") + except OSError as e: + rep.error(name, "-", "", "unreadable", f"Could not read the file: {e}.") + return parsed + + +def records(parsed: dict[str, Any], name: str, rep: Report, required: bool = True) -> list[dict[str, Any]]: + """Pull one dataset out of the parse cache as a list of dicts, reporting anything else.""" + if name not in parsed: + if (ROOT / name).exists(): + return [] # already reported as a parse error by scan_data_dir + if required: + rep.error(name, "-", "", "missing-file", f"Expected data file is missing: {ROOT / name}.") + return [] + value = parsed[name] + if not isinstance(value, list): + rep.error(name, "-", "", "shape", f"Must be a JSON array; got {type(value).__name__}.") + return [] + out: list[dict[str, Any]] = [] + for i, item in enumerate(value): + if isinstance(item, dict): + out.append(item) + else: + rep.error(name, f"[{i}]", "", "shape", f"Entry must be an object; got {type(item).__name__}.") + return out + + +def dup_check(rep: Report, file: str, recs: list[dict[str, Any]], key: str, label: str) -> None: + seen: dict[str, int] = {} + for i, r in enumerate(recs): + v = _s(r, key) + if not v: + rep.error(file, f"[{i}]", key, "required", f"{label} is missing a {key}.") + continue + if v in seen: + rep.error(file, f"[{i}] {key}={v}", key, "duplicate", f'Duplicate {label} {key} "{v}" (first seen at [{seen[v]}]).') + else: + seen[v] = i + + +# --- per-dataset checks --------------------------------------------------------------------------- + + +def check_hazard_vocabulary(rep: Report, hazards: list[dict[str, Any]]) -> None: + file = "data/hazards.json" + dup_check(rep, file, hazards, "id", "Hazard") + dup_check(rep, file, hazards, "token", "Hazard") + for i, h in enumerate(hazards): + where = f"[{i}] id={_s(h, 'id') or '?'}" + token = _s(h, "token") + # types.ts: the token goes in slot 2 of a STAC name, which upstream matches with [^_]+. + if token and "_" in token: + rep.error( + file, where, "token", "underscore", + f'Hazard token "{token}" must not contain an underscore — use CamelCase, e.g. TropicalCyclone.', + ) + if _s(h, "id") and "_" in _s(h, "id"): + rep.error(file, where, "id", "underscore", f'Hazard id "{_s(h, "id")}" must not contain an underscore.') + + +def check_algorithms(rep: Report, algorithms: list[dict[str, Any]], hazard_ids: set[str]) -> None: + file = "data/algorithms.json" + dup_check(rep, file, algorithms, "id", "Algorithm") + for i, a in enumerate(algorithms): + aid = _s(a, "id") + where = f"[{i}] id={aid or '?'}" + + start = _s(a, "temporalStart") + if not start: + rep.error(file, where, "temporalStart", "required", "temporalStart is required (YYYY-MM-DD).") + elif not matches(_ISO_DATE, start): + rep.error(file, where, "temporalStart", "format", f'temporalStart "{start}" must be YYYY-MM-DD.') + + thumb = _s(a, "thumb") + if not thumb: + rep.error(file, where, "thumb", "required", "thumb is required.") + else: + # thumb is a site-absolute URL ("/thumbs/x.png") served out of public/. + on_disk = PUBLIC / thumb.lstrip("/") + if not on_disk.is_file(): + rep.error( + file, where, "thumb", "missing-file", + f'thumb "{thumb}" has no file at {rel(on_disk)}.', + ) + + products = _dicts(a, "products") + # A "utility" algorithm (list_dates) is a DISCOVERY tool — it lists what a vendor bucket + # holds and writes a CSV, it generates no COG — so an empty products array is correct data + # for it and only for it. Anything else with no products can never be requested. + if not products and _s(a, "modality") != "utility": + rep.error( + file, where, "products", "required", + 'Algorithm declares no products, so nothing can be requested from it. ' + 'Only modality "utility" may have an empty products array.', + example=ALGORITHM_PRODUCT_EXAMPLE, + ) + for p in products: + pid = _s(p, "id") + pwhere = f"{where} product={pid or '?'}" + if not pid: + rep.error(file, pwhere, "products", "required", "Product is missing an id.", + example=ALGORITHM_PRODUCT_EXAMPLE) + for hz in _strs(p, "hazards"): + if hz not in hazard_ids: + rep.error( + file, pwhere, "hazards", "vocabulary", + f'Product hazard "{hz}" is not a hazard id in data/hazards.json.', + ) + # primaryHazards drives the Submit form's auto-selection and MUST be a subset of + # hazards — otherwise the form would propose a product for a hazard the catalog's + # own filter says it isn't suited to, and the two views would disagree. + broad = set(_strs(p, "hazards")) + for hz in _strs(p, "primaryHazards"): + if hz not in hazard_ids: + rep.error( + file, pwhere, "primaryHazards", "vocabulary", + f'Product primaryHazard "{hz}" is not a hazard id in data/hazards.json.', + ) + elif hz not in broad: + rep.error( + file, pwhere, "primaryHazards", "not-a-subset", + f'Product primaryHazard "{hz}" is not listed in the same product\'s ' + f'"hazards". primaryHazards must always be a subset of hazards.', + ) + + +def check_events( + rep: Report, + events: list[dict[str, Any]], + hazards: list[dict[str, Any]], + products_by_algorithm: dict[str, set[str]], +) -> None: + file = "data/events.json" + dup_check(rep, file, events, "id", "ActivationEvent") + for i, e in enumerate(events): + eid = _s(e, "id") + where = f"[{i}] id={eid or '?'}" + + # THE STANDARD, applied to committed history. This is the whole point of the app: today + # nothing checks, which is why Fire/Wildfire and Quake/Earthquake both exist in the wild. + for v in check_name(_s(e, "name")): + rep.add(file, where, v) + for v in check_stac_name(_s(e, "stacName"), hazards, _strs(e, "hazards")): + rep.add(file, where, v) + for v in check_dates(_s(e, "start"), _s(e, "end")): + rep.add(file, where, v) + for v in check_hazards(_strs(e, "hazards"), hazards): + rep.add(file, where, v) + for v in check_locations(_strs(e, "locations")): + rep.add(file, where, v) + for v in check_bbox(_s(e, "bbox")): + rep.add(file, where, v) + + # types.ts: ActivationEvent.id == stacName. + stac = _s(e, "stacName") + if eid and stac and eid != stac: + rep.error(file, where, "id", "event-id-mismatch", f'id "{eid}" must equal stacName "{stac}".') + + check_layers(rep, file, where, "layers", _dicts(e, "layers"), products_by_algorithm) + + +def check_layers( + rep: Report, + file: str, + where: str, + field: str, + layers: list[dict[str, Any]], + products_by_algorithm: dict[str, set[str]], +) -> None: + """Referential integrity for {algorithm, product} pairs — shared by events and requests.""" + for layer in layers: + alg = _s(layer, "algorithm") + prod = _s(layer, "product") + if not alg or not prod: + rep.error(file, where, field, "required", f'Layer needs both "algorithm" and "product"; got {layer!r}.') + continue + if alg not in products_by_algorithm: + rep.error( + file, where, field, "algorithm-ref", + f'"{alg}" is not an algorithm id in data/algorithms.json.', + ) + continue + if prod not in products_by_algorithm[alg]: + known = ", ".join(sorted(products_by_algorithm[alg])) or "(none)" + rep.error( + file, where, field, "product-ref", + f'Algorithm "{alg}" has no product "{prod}". Its products are: {known}.', + ) + + +def check_requests( + rep: Report, + parsed: dict[str, Any], + hazards: list[dict[str, Any]], + products_by_algorithm: dict[str, set[str]], +) -> None: + """Every submission under data/requests/ (and the compacted data/requests.json, if present).""" + files = sorted(f for f in parsed if f.startswith("data/requests/") and f.endswith(".json")) + if "data/requests.json" in parsed: + files.append("data/requests.json") + + for file in files: + value = parsed[file] + # A submission file holds one request; data/requests.json (written by scripts/compact.mjs) + # holds an array of them. Accept either. + docs = value if isinstance(value, list) else [value] + for i, doc in enumerate(docs): + if not isinstance(doc, dict): + rep.error(file, f"[{i}]", "", "shape", f"Request must be an object; got {type(doc).__name__}.") + continue + rid = _s(doc, "id") + where = f"[{i}] id={rid or '?'}" if isinstance(value, list) else f"id={rid or '?'}" + + if _s(doc, "kind") != "algorithm-request": + rep.error(file, where, "kind", "kind", 'kind must be "algorithm-request".') + if not rid: + rep.error(file, where, "id", "required", "Request id is required (compact.mjs upserts by id).") + if not _s(doc, "ts"): + rep.error(file, where, "ts", "required", "Request ts is required (compact.mjs resolves conflicts by ts).") + + event = _obj(doc, "event") + shape = { + "name": _s(event, "name"), + "stacName": _s(event, "stacName"), + "start": _s(event, "start"), + "end": _s(event, "end"), + "hazards": _strs(event, "hazards"), + "locations": _strs(event, "locations"), + "bbox": _s(event, "bbox"), + "products": _dicts(doc, "products"), + } + for v in validate_request(shape, hazards): + rep.add(file, where, v) + + check_layers(rep, file, where, "products", _dicts(doc, "products"), products_by_algorithm) + + +# --- driver ----------------------------------------------------------------------------------------- + + +def run() -> Report: + rep = Report() + parsed = scan_data_dir(rep) + + hazards = records(parsed, "data/hazards.json", rep) + algorithms = records(parsed, "data/algorithms.json", rep) + events = records(parsed, "data/events.json", rep) + + hazard_ids = {_s(h, "id") for h in hazards if _s(h, "id")} + products_by_algorithm = { + _s(a, "id"): {_s(p, "id") for p in _dicts(a, "products") if _s(p, "id")} + for a in algorithms + if _s(a, "id") + } + + check_hazard_vocabulary(rep, hazards) + check_algorithms(rep, algorithms, hazard_ids) + check_events(rep, events, hazards, products_by_algorithm) + check_requests(rep, parsed, hazards, products_by_algorithm) + return rep + + +# --- reporting --------------------------------------------------------------------------------------- + + +def print_text(rep: Report, quiet: bool) -> None: + shown = rep.errors if quiet else rep.findings + if not quiet: + print(f"Algorithm Catalog data validation — {ROOT}") + print() + + current = None + for f in shown: + if f.file != current: + current = f.file + print(f"{f.file}") + tag = "ERROR" if f.severity == ERROR else "WARN " + print(f" {tag} {f.where} {f.field} [{f.rule}]") + print(f" {f.message}") + if f.example: + print(f" valid example: {f.example}") + if shown: + print() + + n_err, n_warn = len(rep.errors), len(rep.warnings) + if n_err: + print(f"FAILED — {n_err} error(s), {n_warn} warning(s).") + elif not quiet: + print(f"OK — 0 errors, {n_warn} warning(s). Data matches THE STANDARD (src/rules.ts).") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + description="Validate algorithm-catalog data against THE STANDARD (mirror of src/rules.ts).", + ) + ap.add_argument("--json", action="store_true", help="machine-readable output on stdout") + ap.add_argument("--quiet", action="store_true", help="print errors only; print nothing when clean") + args = ap.parse_args(argv) + + rep = run() + if args.json: + print( + json.dumps( + { + "ok": not rep.errors, + "root": str(ROOT), + "counts": {"errors": len(rep.errors), "warnings": len(rep.warnings)}, + "findings": [f.as_dict() for f in rep.findings], + }, + indent=2, + ) + ) + else: + print_text(rep, args.quiet) + return 1 if rep.errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/algorithm-catalog/src/App.tsx b/algorithm-catalog/src/App.tsx new file mode 100644 index 0000000..969cbda --- /dev/null +++ b/algorithm-catalog/src/App.tsx @@ -0,0 +1,91 @@ +// The shell: a flat 3-tab header (no subtabs), the active tab's component, and the requests cart. +// +// State-based navigation, no router (zero new dependencies), synced to the URL hash so a tab is +// linkable and refresh-safe (#/algorithms). The hash listener is what makes an external jump work +// too — see `navigate()` in tabs.ts for the Events → Algorithms case. + +import { useEffect, useState } from "react"; +import { TABS, NAV_EVENT, type NavIntent } from "./tabs"; +import { useRequests } from "./RequestsContext"; +import RequestsCart from "./RequestsCart"; + +function readHash(): string { + return window.location.hash.replace(/^#\/?/, "").split("/")[0]; +} + +export default function App() { + const { items } = useRequests(); + const [tabId, setTabId] = useState(() => { + const h = readHash(); + return TABS.some((t) => t.id === h) ? h : TABS[0].id; + }); + const [cartOpen, setCartOpen] = useState(false); + + const tab = TABS.find((t) => t.id === tabId) ?? TABS[0]; + + useEffect(() => { + window.location.hash = `/${tab.id}`; + }, [tab.id]); + + // A layer chip on an event asks for the Algorithms tab; the tab itself picks up the parked + // intent once it mounts. + useEffect(() => { + const onNav = (e: Event) => { + const intent = (e as CustomEvent).detail; + if (intent?.tab && TABS.some((t) => t.id === intent.tab)) setTabId(intent.tab); + }; + window.addEventListener(NAV_EVENT, onNav); + return () => window.removeEventListener(NAV_EVENT, onNav); + }, []); + + const Active = tab.component; + + return ( +
+
+
+ + 🛰 + + Algorithm Catalog +
+ + + +
+ +
+
+ +
+ +
+ + {cartOpen && setCartOpen(false)} />} +
+ ); +} diff --git a/algorithm-catalog/src/RequestsCart.tsx b/algorithm-catalog/src/RequestsCart.tsx new file mode 100644 index 0000000..3cd6bb6 --- /dev/null +++ b/algorithm-catalog/src/RequestsCart.tsx @@ -0,0 +1,109 @@ +// The staged-requests cart: review everything you've queued, then open ONE prefilled PR. +// Same shape as dse-hub/src/ChangesCart.tsx, with a bigger URL budget (see requests.ts URL_LIMIT). + +import "./request.css"; +import { useRequests } from "./RequestsContext"; +import { buildRequestFile, ghNewFileUrl, REQUEST_DIR, URL_LIMIT } from "./requests"; + +interface Props { + onClose: () => void; +} + +export default function RequestsCart({ onClose }: Props) { + const { items, remove, clear } = useRequests(); + + const { filename, json } = + items.length > 0 ? buildRequestFile(items) : { filename: "", json: "" }; + + const prUrl = items.length > 0 ? ghNewFileUrl(filename, json) : ""; + const urlTooLong = prUrl.length > URL_LIMIT; + + function submitPr() { + if (!prUrl || urlTooLong) return; + window.open(prUrl, "_blank", "noreferrer"); + } + + return ( +
+
e.stopPropagation()} style={{ maxWidth: 620 }}> +
+

🧺 Staged requests

+ +
+ + {items.length === 0 ? ( +

+ No staged requests yet. Fill in the request form and click ➕ Add to requests. +

+ ) : ( + <> +
    + {items.map((item, i) => ( +
  • + + {item.event.name || item.id} + + {item.event.stacName} · {item.event.hazards.join(", ") || "no hazards"} ·{" "} + {item.products.length} product{item.products.length === 1 ? "" : "s"} + + + +
  • + ))} +
+ +
+ +
+ + {urlTooLong && ( +

+ ⚠ Too many requests to fit in the GitHub URL ({prUrl.length.toLocaleString()} chars, + limit {URL_LIMIT.toLocaleString()}). Use Copy JSON and paste it into a new + file under {REQUEST_DIR}/ instead. +

+ )} + + +
{json}
+ +

+ Opens one pull request containing every staged request. Nothing runs until the PR is + reviewed and merged — merging is what puts the event in the catalog. +

+ + )} + +
+ {items.length > 0 && ( + + )} + + +
+
+
+ ); +} diff --git a/algorithm-catalog/src/RequestsContext.tsx b/algorithm-catalog/src/RequestsContext.tsx new file mode 100644 index 0000000..6dd8d74 --- /dev/null +++ b/algorithm-catalog/src/RequestsContext.tsx @@ -0,0 +1,82 @@ +// The staging area for activation requests, mirroring dse-hub/src/ChangesContext.tsx. +// +// Nothing here talks to a server. Requests live in localStorage until the user opens the cart and +// submits them as one prefilled PR — GitHub is the only datastore, and an approved PR is the only +// thing that changes the catalog. + +import { createContext, useContext, useState, type ReactNode } from "react"; +import type { AlgorithmRequest } from "./types"; + +const LS_KEY = "algorithm-catalog:requests"; + +function loadStored(): AlgorithmRequest[] { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as AlgorithmRequest[]; + } + } catch { + /* corrupt or unavailable storage — start empty rather than break the app */ + } + return []; +} + +interface RequestsContextValue { + items: AlgorithmRequest[]; + stage(r: AlgorithmRequest): void; + remove(i: number): void; + clear(): void; +} + +const RequestsContext = createContext({ + items: [], + stage: () => {}, + remove: () => {}, + clear: () => {}, +}); + +export function RequestsProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState(loadStored); + + function persist(next: AlgorithmRequest[]) { + setItems(next); + try { + localStorage.setItem(LS_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + } + + /** Upsert by id: re-staging the same event replaces it in place instead of duplicating. */ + function stage(r: AlgorithmRequest) { + setItems((prev) => { + const idx = prev.findIndex((x) => x.id === r.id); + const next = idx >= 0 ? prev.map((x, i) => (i === idx ? r : x)) : [...prev, r]; + try { + localStorage.setItem(LS_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + return next; + }); + } + + function remove(i: number) { + persist(items.filter((_, idx) => idx !== i)); + } + + function clear() { + persist([]); + } + + return ( + + {children} + + ); +} + +export function useRequests(): RequestsContextValue { + return useContext(RequestsContext); +} diff --git a/algorithm-catalog/src/catalog/AlgorithmCard.tsx b/algorithm-catalog/src/catalog/AlgorithmCard.tsx new file mode 100644 index 0000000..1a7393c --- /dev/null +++ b/algorithm-catalog/src/catalog/AlgorithmCard.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import type { Algorithm, Hazard, Product } from "../types"; +import { hazardColor, hazardIcon, hazardLabel, modality as modalityStyle } from "../colors"; + +interface Props { + algorithm: Algorithm; + /** Products that satisfied the active facets — the chips explaining WHY this card is here. */ + matched: Product[]; + hazards: Hazard[]; + /** The hazards the user actually selected, when that facet is narrowing. Sorted to the front. */ + focusHazards?: Set; + /** True when the selected activation event actually used this algorithm. */ + used?: boolean; + onOpen: () => void; +} + +const MAX_CHIPS = 6; +/** Landsat and Sentinel-2 are suited to all 10 hazards; the whole list would swamp the card. */ +const MAX_HAZARDS = 4; + +export default function AlgorithmCard({ + algorithm: a, + matched, + hazards, + focusHazards, + used, + onOpen, +}: Props) { + // public/thumbs/*.png are illustrative and may simply not be there yet. A broken-image icon is + // the worst possible answer, so the first error swaps in a flat modality-colored block. + const [thumbBroken, setThumbBroken] = useState(false); + const m = modalityStyle(a.modality); + const extra = matched.length - MAX_CHIPS; + + // Hazards this card can speak to, deduped across its matched products — the quickest read of + // "why would I deploy this?". When a hazard facet is narrowing, the hazards the user actually + // asked for sort to the front (Array#sort is stable, so everything else keeps catalog order), + // otherwise the truncation could hide the very hazard that produced the match. + const cardHazards = [...new Set(matched.flatMap((p) => p.hazards))]; + const ordered = focusHazards + ? [...cardHazards].sort((x, y) => Number(focusHazards.has(y)) - Number(focusHazards.has(x))) + : cardHazards; + const shownHazards = ordered.slice(0, MAX_HAZARDS); + const moreHazards = ordered.length - shownHazards.length; + + return ( + + ); +} diff --git a/algorithm-catalog/src/catalog/AlgorithmDetail.tsx b/algorithm-catalog/src/catalog/AlgorithmDetail.tsx new file mode 100644 index 0000000..198690d --- /dev/null +++ b/algorithm-catalog/src/catalog/AlgorithmDetail.tsx @@ -0,0 +1,153 @@ +import type { Algorithm, Hazard } from "../types"; +import { hazardColor, hazardIcon, hazardLabel, modality as modalityStyle } from "../colors"; + +interface Props { + algorithm: Algorithm; + hazards: Hazard[]; + /** Product ids currently satisfying the filters — highlighted so the match stays visible here. */ + matchedIds: Set; + onClose: () => void; +} + +/** How the algorithm is pointed at a place. Mutually incompatible across algorithms, by design. */ +const SELECTOR_LABEL: Record = { + bbox: "WGS84 bounding box — min_lon,min_lat,max_lon,max_lat", + "mgrs-tile": "Sentinel-2 MGRS tile ID", + "wrs2-pathrow": "Landsat WRS-2 path/row (NNNNNN)", + "vendor-scene": "Vendor scene — the AOI is whatever the scene covers; select by date", + "granule-file": "Operator-supplied granule (.tar / .zip)", + none: "No spatial input", +}; + +export default function AlgorithmDetail({ algorithm: a, hazards, matchedIds, onClose }: Props) { + const m = modalityStyle(a.modality); + + return ( +
+
e.stopPropagation()}> +
+

+ {m.icon} {a.title} +

+ +
+ +
+ + {m.label} + + {a.status === "prototype" ? ( + prototype — no DPS algorithm registered + ) : ( + production + )} + {a.vendor} +
+ +

{a.description}

+ +
+
DPS algorithm
+
+ {a.algorithmName} @ {a.version} +
+
Resolution
+
{a.resolution}
+
Data from
+
+ {a.temporalStartApprox ? "~" : ""} + {a.temporalStart} + {a.temporalStartApprox && ( + (mission launch — approximate record start) + )} +
+
Spatial input
+
{SELECTOR_LABEL[a.spatialSelector] ?? a.spatialSelector}
+
Resources
+
+ {a.resources.ramMin} GB RAM · {a.resources.coresMin} cores · {a.resources.outdirMax} GB + output +
+ {a.keywords.length > 0 && ( + <> +
Keywords
+
{a.keywords.join(", ")}
+ + )} +
+ +

+ Products ({a.products.length}) +

+
    + {a.products.map((p) => ( +
  • +
    + {p.label} + {p.id} + → {p.folder}/ + {matchedIds.has(p.id) && matches filters} +
    + {p.note &&

    {p.note}

    } +
    + {p.hazards.map((h) => ( + + {hazardIcon(h, hazards)} {hazardLabel(h, hazards)} + + ))} + {p.hazards.length === 0 && no hazard mapping} +
    +
  • + ))} +
+ +

+ Job inputs ({a.inputs.length}) +

+
+ + + + + + + + + + + {a.inputs.map((i) => ( + + + + + + + ))} + +
NameTypeRequiredDefault
+ {i.name} +
{i.label}
+ {i.doc &&
{i.doc}
} +
{i.type}{i.required ? yes : "no"}{i.default ? {i.default} : }
+
+ +

Resources

+

+ Thumbnail: {a.thumbCredit}. Every figure above mirrors the algorithm's upstream + algorithm_config.yaml — it is what DPS will actually enforce at run time. +

+ + +
+
+ ); +} diff --git a/algorithm-catalog/src/catalog/Catalog.tsx b/algorithm-catalog/src/catalog/Catalog.tsx new file mode 100644 index 0000000..9d7ed1f --- /dev/null +++ b/algorithm-catalog/src/catalog/Catalog.tsx @@ -0,0 +1,163 @@ +// "A wildfire just activated — which products should I deploy?" +// +// Two entry modes over ONE filter state. `🔥 Start from hazard` is the primary flow; +// `🛰 Start from sensor & product` is the escape hatch for someone who knows the product they want +// but not which hazard bucket it lives in. The segmented control only reorders which pickers lead +// (see FilterBar) — it never resets or hides a selection, so switching modes is lossless. + +import { useEffect, useMemo, useState } from "react"; +import { loadAlgorithms, loadEvents, loadHazards } from "../data"; +import { takeNavIntent, NAV_EVENT, type NavIntent } from "../tabs"; +import FilterBar from "../filters/FilterBar"; +import { + allSelected, + applyEvent, + eventAlgorithms, + filterAlgorithms, + isFacetActive, + matchedProducts, + universeOf, + type EntryMode, + type Filters, +} from "../filters/filter"; +import AlgorithmCard from "./AlgorithmCard"; +import AlgorithmDetail from "./AlgorithmDetail"; + +export default function Catalog() { + const algorithms = useMemo(() => loadAlgorithms(), []); + const hazards = useMemo(() => loadHazards(), []); + const events = useMemo(() => loadEvents(), []); + const universe = useMemo(() => universeOf(algorithms, hazards), [algorithms, hazards]); + + const [mode, setMode] = useState("hazard"); + const [filters, setFilters] = useState(() => allSelected(universe)); + const [openId, setOpenId] = useState(null); + + // Arriving from an event's layer chip: prefill from that event, then narrow to the one algorithm + // that was clicked. Handled both on mount (the usual case — this tab was not rendered yet) and + // live, for the case where the tab is already showing. + useEffect(() => { + function apply(intent: NavIntent) { + setFilters((prev) => { + let next = prev; + if (intent.event) { + next = applyEvent(next, universe, events.find((e) => e.id === intent.event) ?? null); + } + if (intent.algorithm) next = { ...next, sensors: new Set([intent.algorithm]) }; + return next; + }); + if (intent.algorithm) setMode("sensor"); + } + + const pending = takeNavIntent(); + if (pending) apply(pending); + + const onNav = (e: Event) => { + const intent = (e as CustomEvent).detail; + if (intent?.tab === "algorithms") { + takeNavIntent(); // consume so a later mount does not re-apply it + apply(intent); + } + }; + window.addEventListener(NAV_EVENT, onNav); + return () => window.removeEventListener(NAV_EVENT, onNav); + }, [events, universe]); + + const selectedEvent = events.find((e) => e.id === filters.eventId) ?? null; + const usedHere = useMemo(() => eventAlgorithms(selectedEvent), [selectedEvent]); + + const shown = useMemo(() => { + const list = filterAlgorithms(algorithms, filters, universe); + // With an event selected, what we actually ran last time floats to the top; otherwise the + // hand-curated catalog order stands. + if (usedHere.size === 0) return list; + return [...list].sort( + (a, b) => Number(usedHere.has(b.id)) - Number(usedHere.has(a.id)), + ); + }, [algorithms, filters, universe, usedHere]); + + const open = openId ? (algorithms.find((a) => a.id === openId) ?? null) : null; + + return ( +
+
+
+ + +
+

+ {mode === "hazard" + ? "Pick the hazard that just activated — the list narrows to the algorithms with a product suited to it." + : "Know the product already? Pick the sensor or product directly. Your hazard selection is kept."} +

+
+ + + +
+ + {shown.length} of {algorithms.length} algorithms + + {selectedEvent && ( + + used here = deployed for {selectedEvent.name} + + )} +
+ + {shown.length === 0 ? ( +

+ No algorithm matches these filters. Widen a facet, or clear the date window — the date + test only keeps algorithms whose record had already started. +

+ ) : ( +
+ {shown.map((a) => ( + setOpenId(a.id)} + /> + ))} +
+ )} + + {open && ( + p.id))} + onClose={() => setOpenId(null)} + /> + )} +
+ ); +} diff --git a/algorithm-catalog/src/colors.ts b/algorithm-catalog/src/colors.ts new file mode 100644 index 0000000..77fa61f --- /dev/null +++ b/algorithm-catalog/src/colors.ts @@ -0,0 +1,89 @@ +// DATA colors — fixed literals, never derived from the theme. +// +// House rule (CLAUDE.md): "Only chrome adapts. Data colors stay fixed." A hazard color and a +// modality accent both CARRY MEANING (which hazard this is, what kind of sensor produced it), so +// they are hex values here rather than CSS custom properties. Everything structural — header, +// borders, body text — themes through the tokens in styles.css; nothing in this file does. + +import type { Hazard, Modality } from "./types"; + +export interface ModalityStyle { + /** Strong accent: card left border, badge background, legend swatch. */ + color: string; + /** Soft wash: badge/chip background behind `ink`, thumbnail fallback block. */ + tint: string; + /** Text color that stays legible on `tint`. */ + ink: string; + label: string; + icon: string; +} + +/** + * The four sensing modalities. Blue = optical (visible/reflective), purple = SAR (active + * microwave, nothing like a photograph), amber-on-indigo = night lights (city glow against a night + * sky), grey = utility (list_dates and friends: no imagery at all). + */ +export const MODALITY: Record = { + optical: { color: "#0076d6", tint: "#e1f0fb", ink: "#0b3d6b", label: "Optical", icon: "🛰️" }, + sar: { color: "#8544ba", tint: "#f1e7f9", ink: "#4b1f6f", label: "SAR", icon: "📡" }, + nightlights: { color: "#c2830a", tint: "#fbf1d6", ink: "#3f2a8c", label: "Night lights", icon: "🌃" }, + utility: { color: "#71767a", tint: "#f0f0f0", ink: "#3d4551", label: "Utility", icon: "🧰" }, +}; + +export const MODALITY_ORDER: Modality[] = ["optical", "sar", "nightlights", "utility"]; + +const UNKNOWN_MODALITY: ModalityStyle = { + color: "#71767a", + tint: "#f0f0f0", + ink: "#3d4551", + label: "Unknown", + icon: "❔", +}; + +/** Tolerant lookup: data is hand-maintained, so an unrecognized modality must not crash a card. */ +export function modality(id: string): ModalityStyle { + return MODALITY[id as Modality] ?? UNKNOWN_MODALITY; +} + +// --------------------------------------------------------------------------------------------- +// Hazards +// +// Every hazard in data/hazards.json carries its own `color` and `icon` — that file is the +// controlled vocabulary and therefore the source of truth. These helpers only add the fallback +// for an id that is NOT in the vocabulary (a stale request file, a typo'd event), so an unknown +// hazard still renders as a stable, distinguishable chip instead of disappearing. +// --------------------------------------------------------------------------------------------- + +const HAZARD_FALLBACK = [ + "#a63a1c", "#1a6b8a", "#4a6b1a", "#6b1a5c", "#1a3a6b", + "#8a5a1a", "#2f6b52", "#6b2f2f", "#3f3f8a", "#5c5c1a", +]; + +function hash(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function find(id: string, vocabulary: Hazard[]): Hazard | undefined { + const needle = id.toLowerCase(); + return vocabulary.find( + (h) => h.id.toLowerCase() === needle || h.aliases.some((a) => a.toLowerCase() === needle), + ); +} + +export function hazardColor(id: string, vocabulary: Hazard[]): string { + return find(id, vocabulary)?.color ?? HAZARD_FALLBACK[hash(id.trim().toLowerCase()) % HAZARD_FALLBACK.length]; +} + +export function hazardIcon(id: string, vocabulary: Hazard[]): string { + return find(id, vocabulary)?.icon ?? "⚠️"; +} + +/** Falls back to the raw id so an off-vocabulary hazard is visible rather than silently blank. */ +export function hazardLabel(id: string, vocabulary: Hazard[]): string { + return find(id, vocabulary)?.label ?? id; +} diff --git a/algorithm-catalog/src/data.ts b/algorithm-catalog/src/data.ts new file mode 100644 index 0000000..0898406 --- /dev/null +++ b/algorithm-catalog/src/data.ts @@ -0,0 +1,233 @@ +// GitHub-only datastore: every dataset is committed JSON under data/, bundled at BUILD time. +// No runtime fetch, no CDN staleness, no env vars — Netlify rebuilds when a PR merges, and that +// merge is the only thing that changes what this app shows. +// +// Mirrors dse-hub/src/meetings/data.ts: canonical arrays imported directly, plus an +// import.meta.glob overlay of the files that submissions create. + +import type { + ActivationEvent, + Algorithm, + AlgorithmRequest, + EventLayer, + Hazard, + Product, +} from "./types"; +import algorithmsRaw from "../data/algorithms.json"; +import eventsRaw from "../data/events.json"; +import hazardsRaw from "../data/hazards.json"; +import requestsRaw from "../data/requests.json"; + +/** + * Submitted requests arrive as data/requests/-.json (one file per PR), and + * scripts/compact.mjs later folds them into the canonical data/requests.json and DELETES the + * individual files. Both sources therefore have to be read — globbing only the directory would + * make every compacted request vanish from the Event Catalog the moment the workflow runs. + * + * A file may hold a single request or an array of them (the cart can stage several), so the + * module type covers both and `asArray` normalizes. + */ +const requestMods = import.meta.glob<{ default: AlgorithmRequest | AlgorithmRequest[] }>( + "../data/requests/*.json", + { eager: true }, +); + +function asArray(v: AlgorithmRequest | AlgorithmRequest[] | undefined): AlgorithmRequest[] { + if (!v) return []; + return Array.isArray(v) ? v : [v]; +} + +// --------------------------------------------------------------------------------------------- +// Loaders +// --------------------------------------------------------------------------------------------- + +/** + * Catalog order is the order in data/algorithms.json — it is hand-curated (optical first, then + * SAR, then night lights) and request/drafts.ts `autoProducts` derives hazard products in that + * same order, so DO NOT sort here. + */ +export function loadAlgorithms(): Algorithm[] { + return (algorithmsRaw as Algorithm[]).filter((a) => a && a.id && a.title); +} + +/** Newest activation first — a responder cares about the most recent comparable event. */ +export function loadEvents(): ActivationEvent[] { + return [...(eventsRaw as ActivationEvent[])] + .filter((e) => e && e.id && e.stacName) + .sort((a, b) => b.start.localeCompare(a.start) || a.name.localeCompare(b.name)); +} + +/** Vocabulary order is curated (most-activated hazard first); left as authored. */ +export function loadHazards(): Hazard[] { + return (hazardsRaw as Hazard[]).filter((h) => h && h.id && h.token); +} + +/** + * Everything submitted through the request form: the compacted canonical array plus every + * not-yet-compacted file, upserted by `id` with the newest `ts` winning — exactly how dse-hub + * merges `meetings.json` with `changes/*.json`. + * + * `resolveJsonModule` types the currently-empty requests.json as `never[]`, hence the double cast. + */ +export function loadRequests(): AlgorithmRequest[] { + const map = new Map(); + for (const r of requestsRaw as unknown as AlgorithmRequest[]) { + if (r && r.id && r.event) map.set(r.id, r); + } + + const overlay: AlgorithmRequest[] = []; + for (const path in requestMods) { + overlay.push(...asArray(requestMods[path]?.default)); + } + overlay.sort((a, b) => a.ts.localeCompare(b.ts)); // oldest → newest, so the newest wins on upsert + for (const r of overlay) { + if (r && r.id && r.event) map.set(r.id, r); + } + + return [...map.values()].sort((a, b) => b.ts.localeCompare(a.ts)); // newest first for display +} + +// --------------------------------------------------------------------------------------------- +// The event catalog: published activations + submitted requests, in one list +// +// An event a responder submits through the form has to be VISIBLE — otherwise they cannot tell +// whether their request landed. So the Events tab shows the union. The two are never conflated: +// `source` badges each row, and a request that names an existing activation is folded into that +// activation as an update rather than appearing as a second, competing row. +// --------------------------------------------------------------------------------------------- + +export type EventSource = "published" | "submitted"; + +/** One submission attached to a catalog row. */ +export interface EventSubmission { + id: string; + ts: string; + requester?: string; + notes?: string; + layers: EventLayer[]; +} + +export interface CatalogEvent extends ActivationEvent { + source: EventSource; + /** Every request naming this `stacName`, newest first. Empty for an untouched activation. */ + submissions: EventSubmission[]; +} + +function requestLayers(r: AlgorithmRequest): EventLayer[] { + return r.products.map((p) => ({ algorithm: p.algorithm, product: p.product })); +} + +/** + * Published activations + submitted requests, keyed by `stacName`, newest activation first. + * A published event always wins the row; matching requests ride along in `submissions`. + */ +export function loadEventCatalog(): CatalogEvent[] { + const byStac = new Map(); + + for (const e of loadEvents()) { + byStac.set(e.stacName, { ...e, source: "published", submissions: [] }); + } + + // loadRequests() is already newest-first, so the FIRST request naming an unpublished stacName + // becomes the row and every older one rides along in `submissions`. + for (const r of loadRequests()) { + const stac = r.event.stacName || r.id; + const submission: EventSubmission = { + id: r.id, + ts: r.ts, + requester: r.requester, + notes: r.notes, + layers: requestLayers(r), + }; + const existing = byStac.get(stac); + if (existing) { + existing.submissions.push(submission); + continue; + } + byStac.set(stac, { + id: r.id, + name: r.event.name || stac, + stacName: stac, + start: r.event.start, + end: r.event.end, + hazards: [...r.event.hazards], + locations: [...r.event.locations], + bbox: r.event.bbox, + layers: submission.layers, + externalLayers: [], + source: "submitted", + submissions: [submission], + }); + } + + return [...byStac.values()].sort( + (a, b) => b.start.localeCompare(a.start) || a.name.localeCompare(b.name), + ); +} + +// --------------------------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------------------------- + +/** + * Stable key for one algorithm×product pair. + * MUST stay byte-identical to request/drafts.ts `productKey()` — the request form and the product + * facet here address the same pairs, and a different separator would silently stop them matching. + */ +export function productKey(algorithmId: string, productId: string): string { + return `${algorithmId}:${productId}`; +} + +export interface ProductRef { + key: string; + algorithm: Algorithm; + product: Product; +} + +/** Every algorithm×product pair in catalog order — the option list for the Product facet. */ +export function allProducts(algorithms: Algorithm[]): ProductRef[] { + const out: ProductRef[] = []; + for (const algorithm of algorithms) { + for (const product of algorithm.products) { + out.push({ key: productKey(algorithm.id, product.id), algorithm, product }); + } + } + return out; +} + +export function algorithmById(algorithms: Algorithm[], id: string): Algorithm | undefined { + return algorithms.find((a) => a.id === id); +} + +export function productById( + algorithms: Algorithm[], + algorithmId: string, + productId: string, +): Product | undefined { + return algorithmById(algorithms, algorithmId)?.products.find((p) => p.id === productId); +} + +// --------------------------------------------------------------------------------------------- +// Date display +// +// Hand-rolled, no date library (zero new dependencies) — and no `new Date(iso)` either, which +// would parse "2025-01-10" as UTC midnight and render as 9 Jan for anyone west of Greenwich. +// Every date in this app is a plain "YYYY-MM-DD" string, so it is only ever split, never parsed. +// +// These live here rather than next to the event views because BOTH views need them, and importing +// one view from the other would put a cycle between them. +// --------------------------------------------------------------------------------------------- + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +export function formatDate(iso: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) return iso; + return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]} ${m[1]}`; +} + +export function formatRange(start: string, end: string): string { + if (!start && !end) return "no dates"; + if (start === end) return formatDate(start); + return `${formatDate(start)} – ${formatDate(end)}`; +} diff --git a/algorithm-catalog/src/events/EventDetail.tsx b/algorithm-catalog/src/events/EventDetail.tsx new file mode 100644 index 0000000..bc86067 --- /dev/null +++ b/algorithm-catalog/src/events/EventDetail.tsx @@ -0,0 +1,179 @@ +// One activation, in full. The honest part is the coverage line and the greyed-out external +// layers: a real activation pulls in far more than this catalog can produce (OPERA, AVIRIS, GPM, +// commercial optical…), and pretending otherwise would send a responder looking for a button that +// does not exist. So every layer the event needed is listed — the ones we can run are clickable +// and jump to the Algorithms tab; the rest are shown, greyed, and inert. + +import type { Algorithm, Hazard } from "../types"; +import { + algorithmById, + formatRange, + productById, + productKey, + type CatalogEvent, +} from "../data"; +import { hazardColor, hazardIcon, hazardLabel, modality as modalityStyle } from "../colors"; +import { navigate } from "../tabs"; + +interface Props { + event: CatalogEvent; + algorithms: Algorithm[]; + hazards: Hazard[]; + onClose: () => void; +} + +export default function EventDetail({ event: ev, algorithms, hazards, onClose }: Props) { + const resolved = ev.layers.map((l) => ({ + layer: l, + algorithm: algorithmById(algorithms, l.algorithm), + product: productById(algorithms, l.algorithm, l.product), + })); + const available = resolved.filter((r) => r.algorithm && r.product); + const total = ev.layers.length + ev.externalLayers.length; + + function jump(algorithmId: string) { + navigate({ tab: "algorithms", algorithm: algorithmId, event: ev.id }); + onClose(); + } + + return ( +
+
e.stopPropagation()}> +
+

{ev.name}

+ +
+ +
+ {ev.source} + {ev.stacName} + {formatRange(ev.start, ev.end)} +
+ +
+ {ev.hazards.map((h) => ( + + {hazardIcon(h, hazards)} {hazardLabel(h, hazards)} + + ))} +
+ +
+
Locations
+
{ev.locations.join(" · ") || none recorded}
+ {ev.bbox && ( + <> +
Bounding box
+
+ {ev.bbox} +
+ + )} +
+ +

+ + {available.length} of {total} layers + {" "} + available in this catalog. + {ev.externalLayers.length > 0 && ( + <> + {" "} + The remaining {total - available.length} came from products this app does not + catalog — they are listed below, greyed out, so the gap is visible rather than + implied. + + )} +

+ +

+ In this catalog ({available.length}) +

+
+ {resolved.map(({ layer, algorithm, product }) => { + const key = productKey(layer.algorithm, layer.product); + if (!algorithm || !product) { + return ( + + {layer.algorithm} · {layer.product} + + ); + } + const m = modalityStyle(algorithm.modality); + return ( + + ); + })} + {resolved.length === 0 && No catalog layers recorded.} +
+ + {ev.externalLayers.length > 0 && ( + <> +

+ Not in this catalog ({ev.externalLayers.length}) +

+
+ {ev.externalLayers.map((l) => ( + + {l} + + ))} +
+ + )} + + {ev.submissions.length > 0 && ( + <> +

+ Submissions ({ev.submissions.length}) +

+
    + {ev.submissions.map((s) => ( +
  • +
    + {s.requester || "anonymous"} + {s.ts.slice(0, 10)} + + {s.layers.length} product{s.layers.length === 1 ? "" : "s"} + +
    + {s.notes &&

    {s.notes}

    } +
  • + ))} +
+

+ Submissions are requests, not results. Nothing runs until the pull request that + carries them is reviewed and merged. +

+ + )} + +
+ + {available.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/algorithm-catalog/src/events/EventList.tsx b/algorithm-catalog/src/events/EventList.tsx new file mode 100644 index 0000000..5e427e9 --- /dev/null +++ b/algorithm-catalog/src/events/EventList.tsx @@ -0,0 +1,219 @@ +// The Event Catalog: past activations from data/events.json PLUS everything submitted through the +// request form, in one list. +// +// The union is the point. A responder who files a request has to be able to see it — an app that +// swallows a submission until some workflow runs is an app nobody trusts. The two are never +// conflated: every row is badged `published` or `submitted`, and a request naming an activation +// that already exists is folded into that activation as an update instead of competing with it. + +import { useMemo, useState } from "react"; +import type { Hazard } from "../types"; +import { + allProducts, + formatRange, + loadAlgorithms, + loadEventCatalog, + loadHazards, + productKey, + type CatalogEvent, +} from "../data"; +import { hazardColor, hazardIcon, hazardLabel } from "../colors"; +import MultiPicker, { type PickerOption } from "../filters/MultiPicker"; +import DateRange from "../filters/DateRange"; +import { + emptyEventFilters, + filterEvents, + isFacetActive, + universeOf, + type EventFilters, +} from "../filters/filter"; +import EventDetail from "./EventDetail"; + +export default function EventList() { + const algorithms = useMemo(() => loadAlgorithms(), []); + const hazards = useMemo(() => loadHazards(), []); + const events = useMemo(() => loadEventCatalog(), []); + const universe = useMemo(() => universeOf(algorithms, hazards), [algorithms, hazards]); + + const [filters, setFilters] = useState(() => emptyEventFilters(universe)); + const [openId, setOpenId] = useState(null); + + const hazardLabels = useMemo( + () => new Map(hazards.map((h) => [h.id, h.label])), + [hazards], + ); + // Layer key → "Landsat 8/9 Natural color", so searching a human name finds the event. + const layerLabels = useMemo(() => { + const m = new Map(); + for (const a of algorithms) { + for (const p of a.products) m.set(productKey(a.id, p.id), `${a.title} ${p.label}`); + } + return m; + }, [algorithms]); + + // Every algorithm×product pair that actually exists — the honest denominator for "available in + // this catalog". A layer naming an algorithm we do not have counts as unavailable, exactly like + // an externalLayer, no matter that the event file lists it under `layers`. + const validKeys = useMemo( + () => new Set(allProducts(algorithms).map((r) => r.key)), + [algorithms], + ); + + const hazardOptions: PickerOption[] = hazards.map((h) => ({ + id: h.id, + label: `${h.icon} ${h.label}`, + color: h.color, + })); + + const shown = useMemo( + () => filterEvents(events, filters, universe, hazardLabels, layerLabels), + [events, filters, universe, hazardLabels, layerLabels], + ); + + const open = openId ? (events.find((e) => e.id === openId) ?? null) : null; + const narrowed = + filters.query.trim() !== "" || + isFacetActive(filters.hazards, universe.hazards) || + filters.start !== "" || + filters.end !== ""; + + return ( +
+
+
+ + 🔍 + + setFilters({ ...filters, query: e.target.value })} + placeholder="Search events, STAC names, hazards, locations, layers…" + aria-label="Search events" + /> + {filters.query && ( + + )} +
+ + setFilters({ ...filters, hazards: hz })} + searchable + /> + + +
+ +
+ setFilters({ ...filters, start, end })} + startLabel="Active on or after" + endLabel="Active on or before" + /> +
+ +
+ + {shown.length} of {events.length} events + + + published = in the catalog ·{" "} + submitted = filed through this app, awaiting + review + +
+ + {shown.length === 0 ? ( +

No event matches these filters.

+ ) : ( +
    + {shown.map((ev) => ( +
  • + setOpenId(ev.id)} + /> +
  • + ))} +
+ )} + + {open && ( + setOpenId(null)} + /> + )} +
+ ); +} + +function EventRow({ + event: ev, + hazardsVocab, + validKeys, + onOpen, +}: { + event: CatalogEvent; + hazardsVocab: Hazard[]; + validKeys: Set; + onOpen: () => void; +}) { + const total = ev.layers.length + ev.externalLayers.length; + const available = ev.layers.filter((l) => validKeys.has(productKey(l.algorithm, l.product))).length; + return ( + + ); +} diff --git a/algorithm-catalog/src/filters/DateRange.tsx b/algorithm-catalog/src/filters/DateRange.tsx new file mode 100644 index 0000000..7a0b63b --- /dev/null +++ b/algorithm-catalog/src/filters/DateRange.tsx @@ -0,0 +1,54 @@ +// The date window: two native s, no date library (zero new deps — the whole app +// compares "YYYY-MM-DD" strings, which sort correctly as text, so no date math is needed at all). + +interface Props { + start: string; + end: string; + onChange: (start: string, end: string) => void; + /** Honest note about what the window actually tests; omitted on the Events tab. */ + hint?: string; + startLabel?: string; + endLabel?: string; +} + +export default function DateRange({ + start, + end, + onChange, + hint, + startLabel = "Start date", + endLabel = "End date", +}: Props) { + return ( +
+
+
+ + onChange(e.target.value, end)} + /> +
+
+ + onChange(start, e.target.value)} + /> +
+
+ {(start || end) && ( + + )} + {hint &&

{hint}

} +
+ ); +} diff --git a/algorithm-catalog/src/filters/FilterBar.tsx b/algorithm-catalog/src/filters/FilterBar.tsx new file mode 100644 index 0000000..1d43491 --- /dev/null +++ b/algorithm-catalog/src/filters/FilterBar.tsx @@ -0,0 +1,314 @@ +// The Algorithms toolbar: one search box, four faceted pickers, and the context row (activation +// event, AOI, date window). +// +// `mode` NEVER touches the filter state — it only decides which picker leads and which quick-pick +// pill row is shown, so flipping between "start from hazard" and "start from sensor & product" +// keeps every selection the user has already made. + +import type { ActivationEvent, Algorithm, Hazard } from "../types"; +import { hazardColor, hazardIcon, hazardLabel, modality as modalityStyle } from "../colors"; +import { allProducts } from "../data"; +import MultiPicker, { type PickerOption } from "./MultiPicker"; +import DateRange from "./DateRange"; +import { + activeFacetCount, + allSelected, + applyEvent, + eventsMatchingAoi, + isFacetActive, + type EntryMode, + type Filters, + type Universe, +} from "./filter"; + +interface Props { + mode: EntryMode; + filters: Filters; + onChange: (next: Filters) => void; + universe: Universe; + algorithms: Algorithm[]; + hazards: Hazard[]; + events: ActivationEvent[]; +} + +export default function FilterBar({ + mode, + filters: f, + onChange, + universe: u, + algorithms, + hazards, + events, +}: Props) { + const patch = (p: Partial) => onChange({ ...f, ...p }); + + const hazardOptions: PickerOption[] = hazards.map((h) => ({ + id: h.id, + label: `${h.icon} ${h.label}`, + color: h.color, + })); + + const sensorOptions: PickerOption[] = algorithms.map((a) => ({ + id: a.id, + label: a.title, + color: modalityStyle(a.modality).color, + })); + + const productOptions: PickerOption[] = allProducts(algorithms).map((r) => ({ + id: r.key, + label: r.product.label, + group: r.algorithm.title, + color: modalityStyle(r.algorithm.modality).color, + })); + + const modalityOptions: PickerOption[] = u.modalities.map((m) => ({ + id: m, + label: modalityStyle(m).label, + color: modalityStyle(m).color, + })); + + /** + * Quick-pick pills. The first click on a pill while the facet is wide open NARROWS TO THAT ONE — + * "a wildfire just activated" should be one click, not clear-all-then-pick. After that it is a + * plain toggle, and un-picking the last one returns the facet to "all", i.e. no filter. + */ + function pill(current: Set, all: string[], id: string): Set { + if (!isFacetActive(current, all)) return new Set([id]); + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next.size === 0 ? new Set(all) : next; + } + + const selectedEvent = events.find((e) => e.id === f.eventId) ?? null; + const aoiHits = eventsMatchingAoi(events, f.aoi); + const activeCount = activeFacetCount(f, u); + + const hazardPickerLead = mode === "hazard"; + + return ( +
+
+
+ + 🔍 + + patch({ query: e.target.value })} + placeholder="Search algorithms, vendors, products, keywords…" + aria-label="Search algorithms" + /> + {f.query && ( + + )} +
+ + {hazardPickerLead ? ( + <> + patch({ hazards: hz })} + searchable + /> + patch({ modalities: m })} + /> + patch({ sensors: s })} + /> + patch({ products: p })} + searchable + /> + + ) : ( + <> + patch({ sensors: s })} + searchable + /> + patch({ products: p })} + searchable + /> + patch({ modalities: m })} + /> + patch({ hazards: hz })} + searchable + /> + + )} + + +
+ + {hazardPickerLead ? ( +
+ {hazards.map((h) => { + const on = isFacetActive(f.hazards, u.hazards) && f.hazards.has(h.id); + return ( + + ); + })} +
+ ) : ( +
+ {algorithms.map((a) => { + const on = isFacetActive(f.sensors, u.sensors) && f.sensors.has(a.id); + const c = modalityStyle(a.modality).color; + return ( + + ); + })} +
+ )} + +
+
+
+ + +
+ {selectedEvent && ( +

+ Prefilled hazards, dates and AOI from {selectedEvent.name}. The{" "} + {new Set(selectedEvent.layers.map((l) => l.algorithm)).size} algorithms it actually + used are flagged used here below — the list itself + is still filtered by the facets, not by the event. +

+ )} +
+ +
+
+ + patch({ aoi: e.target.value })} + placeholder="Los Angeles, CA" + /> +
+

+ AOI cannot narrow the algorithm list. Every sensor here is globally taskable and no + algorithm in the catalog carries a coverage polygon, so there is nothing for a place + name to be tested against — it searches the locations of past activations{" "} + instead. + {f.aoi.trim() && ( + <> + {" "} + + {aoiHits.length} past activation{aoiHits.length === 1 ? "" : "s"} + + {aoiHits.length > 0 && <>: {aoiHits.map((e) => e.name).join(", ")}}. + + )} +

+
+ +
+ patch({ start, end })} + hint="An algorithm records only when its data record BEGINS — there is no end date upstream — so the window keeps every algorithm whose record started on or before the later date you pick." + /> +
+
+ + {isFacetActive(f.hazards, u.hazards) && f.hazards.size > 0 && ( +
+ {[...f.hazards].map((id) => ( + + {hazardIcon(id, hazards)} {hazardLabel(id, hazards)} + + + ))} +
+ )} +
+ ); +} diff --git a/algorithm-catalog/src/filters/MultiPicker.tsx b/algorithm-catalog/src/filters/MultiPicker.tsx new file mode 100644 index 0000000..d15ff22 --- /dev/null +++ b/algorithm-catalog/src/filters/MultiPicker.tsx @@ -0,0 +1,154 @@ +// ONE multi-select pill+popover, used by every facet in the app. +// +// Factored from dse-hub's TeamFilter (flat list) and SprintFilter (2-level groups) — they were the +// same component twice with a nesting level in between — plus leave-dashboard's PersonPicker +// in-popover search. Grouping turns on when an option carries a `group`; the search box turns on +// with `searchable`. Nothing else about the three differed. + +import { useMemo, useRef, useState } from "react"; +import { useClickAway } from "../useClickAway"; + +export interface PickerOption { + id: string; + label: string; + /** Data color (hazard color, modality accent) — rendered as a swatch, never themed. */ + color?: string; + /** Optional group heading; options with the same group render under one toggleable header. */ + group?: string; +} + +interface Props { + /** Plural noun for the button: "hazards" → "All hazards" / "2 of 10 hazards". */ + label: string; + icon?: string; + options: PickerOption[]; + selected: Set; + onChange: (next: Set) => void; + searchable?: boolean; +} + +export default function MultiPicker({ + label, + icon, + options, + selected, + onChange, + searchable = false, +}: Props) { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const ref = useRef(null); + useClickAway(ref, () => setOpen(false), open); + + const grouped = options.some((o) => o.group); + + // Group order follows first appearance, which keeps catalog order (optical → SAR → night lights) + // instead of re-alphabetizing it. + const groups = useMemo(() => { + const needle = q.trim().toLowerCase(); + const out = new Map(); + for (const o of options) { + if (needle && !o.label.toLowerCase().includes(needle) && !o.id.toLowerCase().includes(needle)) { + const g = (o.group ?? "").toLowerCase(); + if (!g.includes(needle)) continue; + } + const key = o.group ?? ""; + const arr = out.get(key) ?? []; + arr.push(o); + out.set(key, arr); + } + return [...out.entries()]; + }, [options, q]); + + function toggle(id: string) { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + onChange(next); + } + + function toggleGroup(members: PickerOption[]) { + const allOn = members.every((m) => selected.has(m.id)); + const next = new Set(selected); + for (const m of members) { + if (allOn) next.delete(m.id); + else next.add(m.id); + } + onChange(next); + } + + const buttonLabel = + selected.size === options.length + ? `All ${label}` + : selected.size === 0 + ? `No ${label}` + : `${selected.size} of ${options.length} ${label}`; + + const narrowed = selected.size !== options.length; + + return ( +
+ + + {open && ( +
+ {searchable && ( + setQ(e.target.value)} + aria-label={`Search ${label}`} + autoFocus + /> + )} + +
+ + +
+ + {groups.map(([group, members]) => ( +
+ {grouped && group && ( + + )} + {members.map((o) => ( + + ))} +
+ ))} + + {groups.length === 0 &&

No matches

} +
+ )} +
+ ); +} diff --git a/algorithm-catalog/src/filters/filter.ts b/algorithm-catalog/src/filters/filter.ts new file mode 100644 index 0000000..5d6abd7 --- /dev/null +++ b/algorithm-catalog/src/filters/filter.ts @@ -0,0 +1,253 @@ +// The filtering standard for the Algorithms and Events tabs. Pure functions only — no React, no +// data loading — so the predicates can be reasoned about (and reused) on their own. +// +// The one rule that governs every facet: A FULLY-SELECTED FACET IS NOT A FILTER. State starts with +// every option selected, which means "show everything"; a facet only narrows once its selection is +// a strict subset. That keeps "clear this facet" and "select everything" the same gesture, and it +// is why every predicate below takes the universe it is being compared against. + +import type { ActivationEvent, Algorithm, Hazard, Modality, Product } from "../types"; +import { MODALITY_ORDER } from "../colors"; +import { productKey, type CatalogEvent } from "../data"; + +/** Which pickers lead the Algorithms toolbar. Both modes share ONE filter state. */ +export type EntryMode = "hazard" | "sensor"; + +export interface Filters { + hazards: Set; + /** Algorithm ids. */ + sensors: Set; + /** productKey()s. */ + products: Set; + modalities: Set; + /** "" == unset. */ + start: string; + end: string; + /** ActivationEvent["id"], "" == none. Prefills the other facets; never filters by itself. */ + eventId: string; + aoi: string; + query: string; +} + +/** Every option each facet could hold — the denominator for "is this facet actually filtering?". */ +export interface Universe { + hazards: string[]; + sensors: string[]; + products: string[]; + modalities: string[]; +} + +/** + * Modalities come from the DATA, not from the `Modality` union: the union still declares + * "utility" but no algorithm uses it since list_dates was dropped, and an option that can never + * match anything is a lie in the UI. + */ +export function universeOf(algorithms: Algorithm[], hazards: Hazard[]): Universe { + const present = new Set(algorithms.map((a) => a.modality)); + return { + hazards: hazards.map((h) => h.id), + sensors: algorithms.map((a) => a.id), + products: algorithms.flatMap((a) => a.products.map((p) => productKey(a.id, p.id))), + modalities: MODALITY_ORDER.filter((m) => present.has(m)) as string[], + }; +} + +/** Initial state: everything selected == nothing filtered. */ +export function allSelected(u: Universe): Filters { + return { + hazards: new Set(u.hazards), + sensors: new Set(u.sensors), + products: new Set(u.products), + modalities: new Set(u.modalities), + start: "", + end: "", + eventId: "", + aoi: "", + query: "", + }; +} + +/** A facet narrows only when its selection is a strict subset (0 selected means "match nothing"). */ +export function isFacetActive(selected: Set, universe: string[]): boolean { + return selected.size !== universe.length; +} + +export function activeFacetCount(f: Filters, u: Universe): number { + let n = 0; + if (isFacetActive(f.hazards, u.hazards)) n++; + if (isFacetActive(f.sensors, u.sensors)) n++; + if (isFacetActive(f.products, u.products)) n++; + if (isFacetActive(f.modalities, u.modalities)) n++; + if (f.start || f.end) n++; + if (f.eventId) n++; + if (f.aoi.trim()) n++; + if (f.query.trim()) n++; + return n; +} + +// --------------------------------------------------------------------------------------------- +// Algorithms +// --------------------------------------------------------------------------------------------- + +/** Everything the Algorithms search box searches, per the tab spec. */ +export function algorithmHaystack(a: Algorithm): string { + return [ + a.title, + a.algorithmName, + a.vendor, + a.description, + a.id, + a.resolution, + ...a.keywords, + ...a.products.flatMap((p) => [p.id, p.label]), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +export function matchesQuery(a: Algorithm, q: string): boolean { + const needle = q.trim().toLowerCase(); + return !needle || algorithmHaystack(a).includes(needle); +} + +/** + * The date facet. An algorithm records only when its record BEGINS — there is no temporal end + * upstream — so the only sound test is "did data exist by the time this window closed?". + * The bound is the later of the two inputs, so a start-only window still narrows sensibly. + */ +export function passesDate(a: Algorithm, f: Filters): boolean { + const bound = f.end || f.start; + return !bound || a.temporalStart <= bound; +} + +/** Products of `a` that satisfy the hazard and product facets — the chips shown on its card. */ +export function matchedProducts(a: Algorithm, f: Filters, u: Universe): Product[] { + const byHazard = isFacetActive(f.hazards, u.hazards); + const byProduct = isFacetActive(f.products, u.products); + if (!byHazard && !byProduct) return a.products; + return a.products.filter( + (p) => + (!byHazard || p.hazards.some((h) => f.hazards.has(h))) && + (!byProduct || f.products.has(productKey(a.id, p.id))), + ); +} + +export function filterAlgorithms(algorithms: Algorithm[], f: Filters, u: Universe): Algorithm[] { + const byHazard = isFacetActive(f.hazards, u.hazards); + const bySensor = isFacetActive(f.sensors, u.sensors); + const byProduct = isFacetActive(f.products, u.products); + const byModality = isFacetActive(f.modalities, u.modalities); + + return algorithms.filter((a) => { + // Hazard: the algorithm matches if ANY of its products claims a selected hazard. + if (byHazard && !a.products.some((p) => p.hazards.some((h) => f.hazards.has(h)))) return false; + if (bySensor && !f.sensors.has(a.id)) return false; + if (byProduct && !a.products.some((p) => f.products.has(productKey(a.id, p.id)))) return false; + if (byModality && !f.modalities.has(a.modality as Modality)) return false; + if (!passesDate(a, f)) return false; + return matchesQuery(a, f.query); + }); +} + +/** + * Selecting an activation PREFILLS the facets from it — hazards, the date window, and the first + * location as the AOI text. It never filters on its own; the algorithms it actually used are + * surfaced by `eventAlgorithms()` as a highlight instead, so a responder can see "what we ran last + * time" without losing everything else the filters would have shown them. + */ +export function applyEvent(f: Filters, u: Universe, ev: ActivationEvent | null): Filters { + if (!ev) return { ...f, eventId: "" }; + const known = ev.hazards.filter((h) => u.hazards.includes(h)); + return { + ...f, + eventId: ev.id, + hazards: new Set(known.length > 0 ? known : u.hazards), + start: ev.start, + end: ev.end, + aoi: ev.locations[0] ?? "", + }; +} + +export function eventAlgorithms(ev: ActivationEvent | null | undefined): Set { + return new Set(ev ? ev.layers.map((l) => l.algorithm) : []); +} + +/** + * AOI matching, and the reason the AOI box cannot narrow the algorithm list: it matches an + * EVENT's recorded locations. No algorithm in the catalog carries a coverage polygon — every one + * of these sensors is globally taskable — so there is nothing on an algorithm for a place name to + * be tested against. + */ +export function eventsMatchingAoi( + events: T[], + aoi: string, +): T[] { + const needle = aoi.trim().toLowerCase(); + if (!needle) return []; + return events.filter( + (e) => + e.locations.some((l) => l.toLowerCase().includes(needle)) || + e.name.toLowerCase().includes(needle), + ); +} + +// --------------------------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------------------------- + +export interface EventFilters { + query: string; + hazards: Set; + start: string; + end: string; +} + +export function emptyEventFilters(u: Universe): EventFilters { + return { query: "", hazards: new Set(u.hazards), start: "", end: "" }; +} + +/** + * The Events search box searches the event's own text plus the human names of the layers it used, + * so "Black Marble" finds the Venezuela earthquake even though the event only stores `blackmarble`. + */ +export function eventHaystack( + ev: CatalogEvent, + hazardLabels: Map, + layerLabels: Map, +): string { + return [ + ev.name, + ev.stacName, + ...ev.hazards.map((h) => `${h} ${hazardLabels.get(h) ?? ""}`), + ...ev.locations, + ...ev.layers.map((l) => `${l.algorithm} ${l.product} ${layerLabels.get(productKey(l.algorithm, l.product)) ?? ""}`), + ...ev.externalLayers, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +/** Two windows overlap unless one ends before the other begins. */ +function overlaps(evStart: string, evEnd: string, start: string, end: string): boolean { + if (start && evEnd < start) return false; + if (end && evStart > end) return false; + return true; +} + +export function filterEvents( + events: CatalogEvent[], + f: EventFilters, + u: Universe, + hazardLabels: Map, + layerLabels: Map, +): CatalogEvent[] { + const byHazard = isFacetActive(f.hazards, u.hazards); + const needle = f.query.trim().toLowerCase(); + return events.filter((ev) => { + if (byHazard && !ev.hazards.some((h) => f.hazards.has(h))) return false; + if (!overlaps(ev.start, ev.end, f.start, f.end)) return false; + return !needle || eventHaystack(ev, hazardLabels, layerLabels).includes(needle); + }); +} diff --git a/algorithm-catalog/src/main.tsx b/algorithm-catalog/src/main.tsx new file mode 100644 index 0000000..59fa27d --- /dev/null +++ b/algorithm-catalog/src/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { RequestsProvider } from "./RequestsContext"; +import "./styles.css"; +import "./request.css"; + +// RequestsProvider wraps everything because the staged-requests cart lives in the header (App) +// while requests are added from inside the Submit tab — both need the same store. +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + , +); diff --git a/algorithm-catalog/src/request.css b/algorithm-catalog/src/request.css new file mode 100644 index 0000000..8b00ee8 --- /dev/null +++ b/algorithm-catalog/src/request.css @@ -0,0 +1,696 @@ +/* ============================================================================================ + * Request form styles — everything here is scoped to the submission half of the app. + * + * Shared house classes (.btn .panel .field .hint .overlay .modal .foot .jsonlabel .urlbox .opt + * .rm .seg .control .popover) live in src/styles.css and are REUSED, not redefined. Everything + * genuinely new is prefixed .req- so the two files can never collide. + * + * Colors come from the USWDS custom properties in src/styles.css. Each var() carries a literal + * USWDS fallback so this file still renders correctly on its own (and while styles.css is being + * written alongside it) — the fallback is the same token value, never a new brand color. + * ============================================================================================ */ + +.req-form { + max-width: 900px; + margin: 0 auto; + padding-bottom: 3rem; +} + +/* --- intro ---------------------------------------------------------------------------------- */ + +.req-intro { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: 0.6rem 1rem; + margin: 0 0 1rem; +} + +.req-intro h2 { + grid-column: 1; + margin: 0; + font-size: 1.35rem; +} + +.req-intro p { + grid-column: 1; + margin: 0; + max-width: 62ch; + font-size: 0.92rem; + color: var(--base, #565c65); +} + +.req-cartnote { + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; + font-size: 0.8rem; + color: var(--primary-dark, #1a4480); + background: var(--primary-lighter, #d9e8f6); + border-radius: 6px; + padding: 0.4rem 0.6rem; + max-width: 15rem; +} + +/* --- sections ------------------------------------------------------------------------------- */ + +.req-section { + padding: 1rem 1.1rem 1.1rem; + margin-bottom: 1rem; +} + +.req-section > h3 { + margin: 0 0 0.75rem; + font-size: 1rem; + color: var(--primary-darker, #162e51); + border-bottom: 2px solid var(--base-lighter, #dfe1e2); + padding-bottom: 0.4rem; +} + +.req-help { + font-size: 0.8rem; + color: var(--base, #565c65); + margin: 0.25rem 0 0.55rem; + line-height: 1.45; +} + +.req-help code, +.req-issues code { + background: var(--base-lightest, #f0f0f0); + border-radius: 4px; + padding: 0.05rem 0.28rem; + font-size: 0.9em; +} + +.req-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.req-muted { + color: var(--base-light, #a9aeb1); +} + +/* --- inline violations ---------------------------------------------------------------------- */ + +.req-issues { + list-style: none; + margin: 0.35rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.req-issues li { + display: flex; + align-items: baseline; + gap: 0.4rem; + font-size: 0.8rem; + line-height: 1.4; + padding: 0.3rem 0.45rem; + border-radius: 5px; + border-left: 3px solid currentColor; +} + +.req-issue-icon { + flex: 0 0 auto; +} + +.req-issue-rule { + flex: 0 0 auto; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.01em; + opacity: 0.85; + background: rgba(255, 255, 255, 0.65); + border-radius: 4px; + padding: 0.02rem 0.3rem; +} + +/* The two severity treatments. Nothing else in the app should use these two names. */ +.req-error { + color: var(--error, #d54309); + background: #fdeeea; +} + +.req-warn { + color: #8a6100; /* readable text on the amber wash; --warning itself fails contrast as text */ + background: #fff5d6; + border-color: var(--warning, #ffbe2e); +} + +.req-warn .req-issue-icon, +.req-summary.req-warn { + color: #8a6100; +} + +.req-okay { + color: var(--success, #00a91c); + background: #ecf6ec; +} + +.req-flash { + color: var(--primary-dark, #1a4480); + background: var(--primary-lighter, #d9e8f6); +} + +input[aria-invalid="true"] { + border-color: var(--error, #d54309) !important; +} + +/* --- hazards -------------------------------------------------------------------------------- */ + +.req-hazards { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.req-haz { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 2px solid var(--base-lighter, #dfe1e2); + background: var(--white, #ffffff); + color: var(--base-dark, #3d4551); + border-radius: 999px; + padding: 0.32rem 0.7rem; + font: inherit; + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease; +} + +.req-haz:hover { + border-color: var(--base-light, #a9aeb1); +} + +.req-haz.is-on { + font-weight: 600; +} + +.req-haz-token { + font-size: 0.68rem; + opacity: 0.65; + background: transparent; +} + +/* --- repeatable rows ------------------------------------------------------------------------ */ + +.req-row { + display: flex; + gap: 0.4rem; + align-items: center; + margin-bottom: 0.4rem; +} + +.req-row input { + flex: 1; + min-width: 0; +} + +/* --- selected-product filter + per-algorithm groups ------------------------------------------ */ + +/* The filter box only appears once the selection is big enough to need it, so it needs its own + breathing room above the groups rather than relying on a neighbour's margin. */ +.req-prod-search { + margin: 0.75rem 0 1rem; + max-width: 100%; +} + +.req-prod-groups { + display: flex; + flex-direction: column; + gap: 0.9rem; + margin-bottom: 0.9rem; +} + +.req-prod-group { + border-left: 3px solid var(--base-lighter); + padding-left: 0.7rem; +} + +/* Header row: algorithm name, count, then "Remove all" pushed to the right. Without the flex + these three run together as one string ("Landsat 8/93 productsRemove all"). */ +.req-prod-group-hd { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.4rem; +} + +.req-prod-group-name { + font-weight: 600; + font-size: 0.9rem; + color: var(--base-dark); +} + +.req-prod-group-n { + font-size: 0.78rem; + color: var(--base-mid); +} + +.req-group-clear { + margin-left: auto; + font-size: 0.78rem; +} + +/* --- product chips -------------------------------------------------------------------------- */ + +.req-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +/* Standalone note under the optional tile inputs. */ +.req-tile-note { + margin-top: -0.2rem; +} + +.req-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--base-lighter, #dfe1e2); + border-radius: 6px; + background: var(--white, #ffffff); + padding: 0.22rem 0.25rem 0.22rem 0.55rem; + font-size: 0.82rem; + box-shadow: var(--shadow, 0 1px 2px rgba(27, 27, 27, 0.08)); +} + +.req-chip-manual { + border-style: dashed; +} + +.req-chip-alg { + color: var(--base, #565c65); + font-size: 0.75rem; +} + +.req-chip-alg::after { + content: " ›"; + color: var(--base-light, #a9aeb1); +} + +.req-chip-prod { + color: var(--ink, #1b1b1b); + font-weight: 600; +} + +.req-badge { + font-size: 0.63rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + border-radius: 3px; + padding: 0.08rem 0.3rem; +} + +.req-badge-auto { + background: var(--primary-lighter, #d9e8f6); + color: var(--primary-dark, #1a4480); +} + +.req-badge-manual { + background: #efe6f7; + color: #54278f; +} + +.req-badge-warn { + background: #fff5d6; + color: #8a6100; +} + +.req-chip-x { + border: 0; + background: transparent; + color: var(--base, #565c65); + cursor: pointer; + font-size: 1.05rem; + line-height: 1; + padding: 0 0.2rem; +} + +.req-chip-x:hover { + color: var(--error, #d54309); +} + +/* --- product picker popover ------------------------------------------------------------------ */ + +/* The picker is anchored here and overflows into the sections BELOW it in the DOM. Those + sections are later siblings, so without lifting this anchor into its own stacking layer the + Review panel (and its dark JSON box) paints straight over the open popover. Lifting the + anchor — not just the popover — is what actually fixes it, because the popover's z-index + only competes inside its parent's layer. */ +.req-addwrap { + display: inline-block; + position: relative; + z-index: 60; +} + +.req-picker { + width: min(560px, 92vw); + max-height: 420px; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + z-index: 200; + background: var(--white); + box-shadow: var(--shadow-lg, 0 12px 40px rgba(27, 27, 27, 0.18)); +} + +.req-picker-head { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.5rem 0.5rem 0.4rem; + border-bottom: 1px solid var(--base-lighter, #dfe1e2); +} + +.req-picker-search { + flex: 1; + min-width: 0; + border: 1px solid var(--base-light, #a9aeb1); + border-radius: 6px; + padding: 0.35rem 0.5rem; + font: inherit; + font-size: 0.85rem; +} + +.req-picker-search:focus { + outline: 0; + border-color: var(--primary, #005ea2); + box-shadow: 0 0 0 3px var(--primary-lighter, #d9e8f6); +} + +.req-picker-body { + flex: 1; + overflow: auto; + padding: 0.4rem; +} + +.req-picker-group { + margin-bottom: 0.5rem; +} + +.req-picker-group-head { + display: flex; + align-items: baseline; + gap: 0.35rem; + font-size: 0.85rem; + padding: 0.25rem 0.3rem; + position: sticky; + top: 0; + background: var(--white, #ffffff); + border-bottom: 1px dashed var(--base-lighter, #dfe1e2); +} + +.req-picker-meta { + font-size: 0.7rem; + color: var(--base-light, #a9aeb1); +} + +.req-picker-row { + display: flex; + align-items: baseline; + gap: 0.4rem; + width: 100%; + text-align: left; + border: 0; + background: transparent; + border-radius: 5px; + padding: 0.28rem 0.35rem; + font: inherit; + font-size: 0.82rem; + cursor: pointer; +} + +.req-picker-row:hover:not(:disabled) { + background: var(--base-lightest, #f0f0f0); +} + +.req-picker-row.is-added { + cursor: default; + color: var(--base-light, #a9aeb1); +} + +.req-picker-plus { + flex: 0 0 1rem; + color: var(--primary, #005ea2); +} + +.req-picker-row.is-added .req-picker-plus { + color: var(--success, #00a91c); +} + +.req-picker-name { + font-weight: 600; +} + +.req-picker-token { + font-size: 0.72rem; + color: var(--base, #565c65); + background: var(--base-lightest, #f0f0f0); + border-radius: 3px; + padding: 0.02rem 0.28rem; +} + +.req-picker-haz { + margin-left: auto; + font-size: 0.68rem; + color: var(--base-light, #a9aeb1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 40%; +} + +.req-picker-foot { + margin: 0; + padding: 0.4rem 0.6rem; + border-top: 1px solid var(--base-lighter, #dfe1e2); + font-size: 0.73rem; + color: var(--base, #565c65); + background: var(--base-lightest, #f0f0f0); +} + +.req-picker-empty { + margin: 0.3rem 0; + font-size: 0.85rem; + color: var(--base-light, #a9aeb1); +} + +/* --- "What this will run" -------------------------------------------------------------------- */ + +.req-whatruns { + margin-top: 0.9rem; + padding: 0.6rem 0.8rem; + background: var(--bg, #f4f6f9); +} + +.req-whatruns > summary { + cursor: pointer; + font-size: 0.88rem; + font-weight: 600; + color: var(--primary-dark, #1a4480); + list-style: revert; +} + +.req-whatruns-n { + font-weight: 400; + font-size: 0.75rem; + color: var(--base-light, #a9aeb1); +} + +.req-runblock { + margin-top: 0.6rem; + border-top: 1px dashed var(--base-lighter, #dfe1e2); + padding-top: 0.5rem; +} + +.req-runblock-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.4rem; + font-size: 0.88rem; + margin-bottom: 0.3rem; +} + +.req-runtable { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.req-runtable th { + text-align: left; + font-weight: 500; + vertical-align: top; + width: 14rem; + padding: 0.15rem 0.5rem 0.15rem 0; + white-space: nowrap; +} + +.req-runtable td { + padding: 0.15rem 0; + word-break: break-word; +} + +.req-req { + margin-left: 0.35rem; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--error, #d54309); +} + +.req-runval { + background: var(--white, #ffffff); + border: 1px solid var(--base-lighter, #dfe1e2); + border-radius: 3px; + padding: 0.02rem 0.3rem; +} + +/* --- submit bar ------------------------------------------------------------------------------ */ + +.req-submitbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + flex-wrap: wrap; + margin-top: 0.4rem; +} + +.req-summarywrap { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.req-summary { + font-size: 0.85rem; + font-weight: 600; + border-radius: 6px; + padding: 0.4rem 0.7rem; +} + +/* Pristine form: no colour, no alarm — nothing is wrong until the user has typed something. */ +.req-neutral { + font-weight: 400; + color: var(--base, #565c65); + background: var(--base-lightest, #f0f0f0); +} + +.req-submitbar .foot { + flex: 1; +} + +/* --- cart modal ------------------------------------------------------------------------------ */ + +.req-modal-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.req-modal-head h2 { + margin: 0; + font-size: 1.15rem; +} + +.req-modal-x { + border: 0; + background: transparent; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + color: var(--base, #565c65); +} + +.req-cart-list { + list-style: none; + margin: 0.6rem 0 0.4rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.req-cart-row { + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid var(--base-lighter, #dfe1e2); + border-radius: 6px; + padding: 0.45rem 0.6rem; +} + +.req-cart-label { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + font-size: 0.9rem; + color: var(--ink, #1b1b1b); +} + +.req-cart-sub { + font-size: 0.75rem; + color: var(--base, #565c65); +} + +.req-cart-remove { + border: 0; + background: transparent; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + color: var(--base, #565c65); +} + +.req-cart-remove:hover { + color: var(--error, #d54309); +} + +.req-cart-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 0.6rem; +} + +.req-linkbtn { + border: 0; + background: transparent; + color: var(--primary-dark, #1a4480); + text-decoration: underline; + cursor: pointer; + font: inherit; + font-size: 0.82rem; + padding: 0; +} + +/* --- narrow screens -------------------------------------------------------------------------- */ + +@media (max-width: 640px) { + .req-intro { + grid-template-columns: 1fr; + } + + .req-cartnote { + grid-column: 1; + grid-row: auto; + justify-self: start; + max-width: none; + } + + .req-runtable th { + width: 9rem; + white-space: normal; + } +} diff --git a/algorithm-catalog/src/request/ProductPicker.tsx b/algorithm-catalog/src/request/ProductPicker.tsx new file mode 100644 index 0000000..2136f13 --- /dev/null +++ b/algorithm-catalog/src/request/ProductPicker.tsx @@ -0,0 +1,113 @@ +// "Add products" — the escape hatch from hazard auto-selection. +// +// The hazard -> product mapping in data/algorithms.json is hand-built and will never be complete: +// a responder who knows they want VIIRS night lights on a landslide must be able to say so. This +// picker lists EVERY algorithm × product in the catalog, grouped and searchable, and anything +// added here is tagged via:"manual" so the request stays traceable. + +import { useMemo, useState } from "react"; +import type { Algorithm } from "../types"; +import { productKey } from "./drafts"; + +interface Props { + algorithms: Algorithm[]; + /** productKey()s already on the request — shown as "added" and not clickable. */ + selected: Set; + onAdd: (algorithm: string, product: string) => void; + onClose: () => void; +} + +const MODALITY_ICON: Record = { + optical: "🛰️", + sar: "📡", + nightlights: "🌃", + utility: "🧰", +}; + +export default function ProductPicker({ algorithms, selected, onAdd, onClose }: Props) { + const [q, setQ] = useState(""); + + const groups = useMemo(() => { + const needle = q.trim().toLowerCase(); + return algorithms + .map((a) => { + const algMatches = + !needle || + a.title.toLowerCase().includes(needle) || + a.id.toLowerCase().includes(needle) || + a.vendor.toLowerCase().includes(needle) || + a.modality.toLowerCase().includes(needle) || + a.keywords.some((k) => k.toLowerCase().includes(needle)); + const products = a.products.filter( + (p) => + algMatches || + p.label.toLowerCase().includes(needle) || + p.id.toLowerCase().includes(needle) || + p.hazards.some((h) => h.toLowerCase().includes(needle)), + ); + return { alg: a, products }; + }) + .filter((g) => g.products.length > 0); + }, [algorithms, q]); + + const total = groups.reduce((n, g) => n + g.products.length, 0); + + return ( +
e.stopPropagation()}> +
+ setQ(e.target.value)} + placeholder="Search sensors, products, hazards…" + aria-label="Search products" + /> + +
+ +
+ {total === 0 &&

No products match “{q}”.

} + {groups.map((g) => ( +
+
+ {MODALITY_ICON[g.alg.modality] ?? "🛰️"} + {g.alg.title} + + {g.alg.modality} + {g.alg.status === "prototype" ? " · prototype" : ""} + +
+ {g.products.map((p) => { + const key = productKey({ algorithm: g.alg.id, product: p.id }); + const already = selected.has(key); + return ( + + ); + })} +
+ ))} +
+ +

+ Added here counts as manual — it stays on the request even if you change hazards. +

+
+ ); +} diff --git a/algorithm-catalog/src/request/RequestForm.tsx b/algorithm-catalog/src/request/RequestForm.tsx new file mode 100644 index 0000000..3df81b7 --- /dev/null +++ b/algorithm-catalog/src/request/RequestForm.tsx @@ -0,0 +1,745 @@ +// The submission form: describe an activation event, pick products, open a prefilled PR. +// +// DESIGN RULE: the user never learns anything about DPS. No queues, no run_command, no ram_min, no +// credentials, no YAML. They describe the event and choose products; everything a job actually +// needs is derived. The one concession to curiosity is the collapsed "What this will run" panel — +// read-only, so the mapping is traceable but never editable. +// +// VALIDATION: every field renders its own violations from src/rules.ts underneath itself, red for +// errors and amber for warnings, each naming the rule that produced it. Errors disable both submit +// buttons (isSubmittable); warnings never block. +// +// A field's violations stay HIDDEN until it has been touched (blurred, or clicked for the +// click-driven sections). This tab is the app's landing view, so an untouched form must not open +// on a wall of red — "Activation event name is required" before the user has typed a character is +// noise, not help. "Show all problems" reveals everything at once, which is the answer to "why is +// the submit button greyed out?" (a disabled button can't tell you itself — it emits no click). + +import { useMemo, useState } from "react"; +import "../request.css"; +import type { Algorithm, AlgorithmInput, Hazard, RequestProduct } from "../types"; +import type { Violation } from "../rules"; +import { forField, isSubmittable, errorsOnly, validateRequest, STAC_EXAMPLE } from "../rules"; +import { + cleanLocations, + draftToRequest, + draftToShape, + newDraft, + productKey, + syncProducts, + type RequestDraft, +} from "./drafts"; +import ProductPicker from "./ProductPicker"; + +/** Remembers the requester handle across visits — see the note where the draft is initialised. */ +const REQUESTER_KEY = "algorithm-catalog:requester"; +import { useRequests } from "../RequestsContext"; +import { buildRequestFile, ghNewFileUrl, REQUEST_DIR, URL_LIMIT } from "../requests"; + +interface Props { + /** The catalog. Passed in by App (which owns src/data.ts) so this component stays data-agnostic. */ + algorithms: Algorithm[]; + /** The controlled hazard vocabulary from data/hazards.json. */ + hazards: Hazard[]; +} + +// --------------------------------------------------------------------------------------------- +// Inline violation rendering +// --------------------------------------------------------------------------------------------- + +function Issues({ all, field, show }: { all: Violation[]; field: string; show: boolean }) { + const vs = forField(all, field); + if (!show || vs.length === 0) return null; + return ( +
    + {vs.map((v, i) => ( +
  • + {v.severity === "error" ? "⛔" : "⚠"} + {v.field}.{v.rule} + {v.message} +
  • + ))} +
+ ); +} + +// --------------------------------------------------------------------------------------------- +// "What this will run" — derive each algorithm's DPS inputs from what the user typed. +// Purely informational; nothing here is editable and nothing is submitted from it. +// --------------------------------------------------------------------------------------------- + +interface DerivedInput { + input: AlgorithmInput; + value: string; + source: string; +} + +function deriveInput(input: AlgorithmInput, d: RequestDraft, tokens: string[]): DerivedInput { + const n = input.name.toLowerCase(); + const has = (s: string) => n.includes(s); + if (has("activation") || n === "event" || has("event_name")) { + return { input, value: d.stacName, source: "STAC metadata event name" }; + } + if (has("product") || has("layer")) { + return { input, value: tokens.join(" "), source: "products you selected" }; + } + if (has("start") || n === "date_from" || n === "from_date") { + return { input, value: d.start, source: "start date" }; + } + if (has("end") || n === "date_to" || n === "to_date") { + return { input, value: d.end, source: "end date" }; + } + if (has("bbox") || has("bound") || has("extent")) { + return { input, value: d.bbox, source: "bounding box" }; + } + // Single-acquisition algorithms take one `date` (or `download_date` / `process_date`) per job — + // one job per day in the window. Show the first day so the shape of the run is obvious. + if (n === "date" || n.endsWith("_date") || n.startsWith("date")) { + return { input, value: d.start, source: "one job per day from the event window" }; + } + if (input.default !== undefined) { + return { input, value: input.default, source: "algorithm default" }; + } + return { input, value: "", source: input.required ? "set by the operator at run time" : "not set" }; +} + +function WhatWillRun({ + algorithms, + draft, + products, +}: { + algorithms: Algorithm[]; + draft: RequestDraft; + products: RequestProduct[]; +}) { + const byAlg = useMemo(() => { + const m = new Map(); + for (const p of products) { + const list = m.get(p.algorithm) ?? []; + list.push(p.product); + m.set(p.algorithm, list); + } + return m; + }, [products]); + + const rows = algorithms.filter((a) => byAlg.has(a.id)); + + return ( +
+ + 🔍 What this will run {rows.length} algorithm{rows.length === 1 ? "" : "s"} + +

+ Read-only. You never have to fill any of this in — it is derived from the event and products + above, and is shown only so the request is traceable back to the job it becomes. +

+ {rows.length === 0 &&

Pick some products first.

} + {rows.map((a) => { + const tokens = byAlg.get(a.id) ?? []; + return ( +
+
+ {a.title} + {a.algorithmName} + @{a.version} + {a.status === "prototype" && prototype} +
+ + + {a.inputs.map((input) => { + const d = deriveInput(input, draft, tokens); + return ( + + + + + ); + })} + {a.inputs.length === 0 && ( + + + + )} + +
+ {input.name} + {input.required && required} + + {d.value ? {d.value} : } + · {d.source} +
+ This algorithm declares no inputs in its config. +
+
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------------------------- +// The form +// --------------------------------------------------------------------------------------------- + +export default function RequestForm({ algorithms, hazards }: Props) { + // The app has no auth and no backend, so it cannot know your GitHub handle — a static page + // can't read your github.com session. (GitHub captures the real author anyway: whoever clicks + // "Open PR" is signed in there, so the PR carries their handle.) Best we can do is remember + // what you typed last time. + const [draft, setDraft] = useState(() => { + const d = newDraft(); + try { + d.requester = localStorage.getItem(REQUESTER_KEY) ?? ""; + } catch { + /* ignore */ + } + return d; + }); + const [pickerOpen, setPickerOpen] = useState(false); + const [prodQuery, setProdQuery] = useState(""); + const [flash, setFlash] = useState(""); + // Which fields have been interacted with, and the "reveal everything" override. + const [touched, setTouched] = useState>({}); + const [showAll, setShowAll] = useState(false); + // Stable while typing so the JSON preview doesn't churn; refreshed whenever we actually submit. + const [ts, setTs] = useState(() => new Date().toISOString()); + + const { items, stage } = useRequests(); + + const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })); + const touch = (field: string) => setTouched((t) => (t[field] ? t : { ...t, [field]: true })); + const shown = (field: string) => showAll || touched[field] === true; + const pristine = !showAll && Object.keys(touched).length === 0; + + // Labels for the product chips. + const productLabel = useMemo(() => { + const m = new Map(); + for (const a of algorithms) { + for (const p of a.products) { + m.set(productKey({ algorithm: a.id, product: p.id }), { + alg: a.title, + product: p.label, + modality: a.modality, + }); + } + } + return m; + }, [algorithms]); + + const violations = useMemo(() => validateRequest(draftToShape(draft), hazards), [draft, hazards]); + const errCount = errorsOnly(violations).length; + const warnCount = violations.length - errCount; + const ok = isSubmittable(violations); + + const request = useMemo(() => draftToRequest(draft, ts), [draft, ts]); + const { filename, json } = useMemo(() => buildRequestFile([request]), [request]); + const prUrl = useMemo(() => ghNewFileUrl(filename, json), [filename, json]); + const urlTooLong = prUrl.length > URL_LIMIT; + + // --- hazards --------------------------------------------------------------------------------- + function toggleHazard(id: string) { + touch("hazards"); + setDraft((d) => { + const next = d.hazards.includes(id) ? d.hazards.filter((h) => h !== id) : [...d.hazards, id]; + return { ...d, hazards: next, products: syncProducts(d.products, algorithms, next, d.dismissed) }; + }); + } + + // --- locations ------------------------------------------------------------------------------- + const setLocation = (i: number, v: string) => + setDraft((d) => ({ ...d, locations: d.locations.map((l, j) => (j === i ? v : l)) })); + const addLocation = () => setDraft((d) => ({ ...d, locations: [...d.locations, ""] })); + const rmLocation = (i: number) => { + touch("locations"); + setDraft((d) => ({ + ...d, + locations: d.locations.length > 1 ? d.locations.filter((_, j) => j !== i) : [""], + })); + }; + + // --- products -------------------------------------------------------------------------------- + const selectedKeys = useMemo(() => new Set(draft.products.map(productKey)), [draft.products]); + + /** + * Selected products, filtered by the box and bucketed by algorithm. Grouping turns what was a + * flat wall of ~28 chips into a handful of labelled rows, so the algorithm name is stated once + * instead of repeated on every chip. Algorithm order follows `algorithms` (catalog order), and + * products keep their declared order within each group. + */ + const groupedProducts = useMemo(() => { + const q = prodQuery.trim().toLowerCase(); + const keep = draft.products.filter((p) => { + if (!q) return true; + const meta = productLabel.get(productKey(p)); + return `${meta?.alg ?? p.algorithm} ${meta?.product ?? p.product} ${p.algorithm} ${p.product}` + .toLowerCase() + .includes(q); + }); + const order = new Map(algorithms.map((a, i) => [a.id, i])); + const buckets = new Map(); + for (const p of keep) { + const arr = buckets.get(p.algorithm); + if (arr) arr.push(p); + else buckets.set(p.algorithm, [p]); + } + return [...buckets.entries()].sort( + (a, b) => (order.get(a[0]) ?? 99) - (order.get(b[0]) ?? 99), + ); + }, [draft.products, prodQuery, productLabel, algorithms]); + + function removeProduct(p: RequestProduct) { + touch("products"); + const key = productKey(p); + setDraft((d) => ({ + ...d, + products: d.products.filter((x) => productKey(x) !== key), + // Remember dropped hazard-derived picks so a later hazard edit doesn't resurrect them. + dismissed: p.via === "hazard" && !d.dismissed.includes(key) ? [...d.dismissed, key] : d.dismissed, + })); + } + + function addProduct(algorithm: string, product: string) { + touch("products"); + const key = productKey({ algorithm, product }); + setDraft((d) => + d.products.some((x) => productKey(x) === key) + ? d + : { + ...d, + products: [...d.products, { algorithm, product, via: "manual" }], + dismissed: d.dismissed.filter((k) => k !== key), + }, + ); + } + + // --- submit ---------------------------------------------------------------------------------- + function addToRequests() { + if (!ok) return; + const now = new Date().toISOString(); + setTs(now); + stage(draftToRequest(draft, now)); + setFlash(`Staged “${draft.name}” — open 🧺 Requests in the header to submit them together.`); + window.setTimeout(() => setFlash(""), 4000); + } + + function openPr() { + if (!ok || urlTooLong) return; + const now = new Date().toISOString(); + setTs(now); + const built = buildRequestFile([draftToRequest(draft, now)]); + window.open(ghNewFileUrl(built.filename, built.json), "_blank", "noreferrer"); + } + + const autoCount = draft.products.filter((p) => p.via === "hazard").length; + const manualCount = draft.products.length - autoCount; + + return ( +
pickerOpen && setPickerOpen(false)}> +
+

📝 Request an activation

+

+ Describe the event and pick the products you need. We work out which algorithms produce + them — you never touch a job config. Submitting opens a pull request for review; nothing + runs until it is merged. +

+ {items.length > 0 && ( +
+ 🧺 {items.length} staged — submit them together from Requests in the header. +
+ )} +
+ + {/* ---------------------------------------------------------------- 1. Event ------------ */} +
+

1 · Event

+ +
+ + patch({ name: e.target.value })} + onBlur={() => touch("name")} + placeholder="California Wildfires January 2025" + aria-invalid={shown("name") && forField(violations, "name").some((v) => v.severity === "error")} + /> +
The human-readable name shown on the website.
+ +
+ +
+ + patch({ stacName: e.target.value })} + onBlur={() => touch("stacName")} + placeholder={STAC_EXAMPLE} + aria-invalid={shown("stacName") && forField(violations, "stacName").some((v) => v.severity === "error")} + /> +
+ YYYYMM_Hazard_Location — checked live against the same rule the pipeline + uses, e.g. {STAC_EXAMPLE} or 202501_Fire_CA. +
+ +
+ +
+
+ + patch({ start: e.target.value })} + onBlur={() => touch("start")} + /> + +
+
+ + patch({ end: e.target.value })} + onBlur={() => touch("end")} + /> + +
+
+ +
+
+ + { + patch({ requester: e.target.value }); + try { + localStorage.setItem(REQUESTER_KEY, e.target.value); + } catch { + /* ignore */ + } + }} + placeholder="@github-handle or name" + /> +
+ Remembered on this browser. GitHub records the real author of the PR anyway — this is + only needed when you're filing on someone else's behalf. +
+
+
+ + patch({ notes: e.target.value })} + placeholder="Anything the reviewers should know" + /> +
+
+
+ + {/* ---------------------------------------------------------------- 2. Hazards ---------- */} +
+

2 · Hazards

+
+ Pick every hazard this activation covers. Selecting a hazard auto-selects the products + suited to it (you can drop or add any of them in step 4). +
+
+ {hazards.map((h) => { + const on = draft.hazards.includes(h.id); + return ( + + ); + })} + {hazards.length === 0 && ( +

No hazard vocabulary loaded.

+ )} +
+ +
+ + {/* ---------------------------------------------------------------- 3. Locations -------- */} +
+

3 · Locations

+
+ + {draft.locations.map((loc, i) => ( +
+ setLocation(i, e.target.value)} + onBlur={() => touch("locations")} + placeholder="Los Angeles, CA" + aria-label={`Location ${i + 1}`} + /> + +
+ ))} + + +
+ +
+ + patch({ bbox: e.target.value })} + onBlur={() => touch("bbox")} + placeholder="-118.9,33.7,-117.6,34.4" + /> +
+ WGS84 min_lon,min_lat,max_lon,max_lat. Leave blank if you don't have one. +
+ +
+ + {/* Scene selectors — for when you already know the exact tiles instead of a place name. + These map straight onto sentinel2's `tile` and landsat's `process_tile` inputs. */} +
+
+ + patch({ mgrsTiles: e.target.value })} + onBlur={() => touch("mgrsTiles")} + placeholder="T17RLN T17RLM" + /> +
+ MGRS tile IDs, space-separated. Feeds Sentinel-2's tile input. +
+ +
+
+ + patch({ wrs2Tiles: e.target.value })} + onBlur={() => touch("wrs2Tiles")} + placeholder="171035" + /> +
+ WRS-2 path/row, 6 digits. Feeds Landsat's process_tile input. +
+ +
+
+
+ Leave both blank to process everything in the area of interest. Fill one in only if you + already know the exact scenes you need. +
+
+ + {/* ---------------------------------------------------------------- 4. Products --------- */} +
+

4 · Products

+
+ {autoCount} auto-selected from your hazards, {manualCount} added by hand. Anything the + hazard mapping missed, add it yourself — the mapping is a hint, not a limit. +
+ + {/* Search over the SELECTED list. With a broad hazard set this can run to dozens of + chips, so grouping by algorithm + a filter box keeps it scannable. */} + {draft.products.length > 4 && ( +
+ + 🔍 + + setProdQuery(e.target.value)} + placeholder="Filter selected products — sensor or product name…" + aria-label="Filter selected products" + /> + {prodQuery && ( + + )} +
+ )} + +
+ {groupedProducts.map(([algId, rows]) => { + const algLabel = productLabel.get(productKey(rows[0]))?.alg ?? algId; + return ( +
+
+ {algLabel} + + {rows.length} product{rows.length === 1 ? "" : "s"} + + +
+
+ {rows.map((p) => { + const meta = productLabel.get(productKey(p)); + return ( + + {meta?.product ?? p.product} + + {p.via === "hazard" ? "auto" : "manual"} + + + + ); + })} +
+
+ ); + })} + {draft.products.length === 0 && ( +

+ Nothing selected yet — pick a hazard above, or add products directly. +

+ )} + {draft.products.length > 0 && groupedProducts.length === 0 && ( +

No selected product matches “{prodQuery}”.

+ )} +
+ +
e.stopPropagation()}> + + {pickerOpen && ( + setPickerOpen(false)} + /> + )} +
+ + + + +
+ + {/* ---------------------------------------------------------------- 5. Review ----------- */} +
+

5 · Review & submit

+ + +
{json}
+ + {urlTooLong && ( +

+ ⚠ This request is too big for GitHub's prefilled editor ({prUrl.length.toLocaleString()}{" "} + chars, limit {URL_LIMIT.toLocaleString()}). Use 📋 Copy JSON and add the file to{" "} + {REQUEST_DIR}/ by hand. +

+ )} + + {flash &&

✅ {flash}

} + +
+
+
0 + ? "req-summary req-error" + : warnCount > 0 + ? "req-summary req-warn" + : "req-summary req-okay" + } + > + {pristine + ? "📝 Fill in the event, hazards and products — submitting unlocks when every rule passes." + : errCount > 0 + ? `⚠ ${errCount} problem${errCount === 1 ? "" : "s"} to fix` + : warnCount > 0 + ? `⚠ ${warnCount} warning${warnCount === 1 ? "" : "s"} — you can still submit` + : `✅ Ready — ${cleanLocations(draft.locations).length} location${cleanLocations(draft.locations).length === 1 ? "" : "s"}, ${draft.products.length} product${draft.products.length === 1 ? "" : "s"}`} +
+ {errCount > 0 && !showAll && ( + + )} +
+
+ + + +
+
+
+
+ ); +} diff --git a/algorithm-catalog/src/request/drafts.ts b/algorithm-catalog/src/request/drafts.ts new file mode 100644 index 0000000..2a5c676 --- /dev/null +++ b/algorithm-catalog/src/request/drafts.ts @@ -0,0 +1,181 @@ +// The in-form model for one activation request. +// +// Everything here is PURE — no React, no Date.now(), no filename building (the cart owns filenames, +// see src/requests.ts). Mirrors the convention in dse-hub/src/meetings/drafts.ts and +// leave-dashboard/src/drafts.ts: a flat, all-strings draft that the form binds directly to, plus +// one function each way (draft -> record, record -> draft). +// +// The draft is deliberately dumb: every scalar is a string exactly as typed, so the live validator +// in src/rules.ts sees the same text the user sees. Nothing is coerced or "fixed up" behind their +// back — a bad date stays bad until they fix it, and the error says why. + +import type { Algorithm, AlgorithmRequest, RequestProduct } from "../types"; +import type { RequestShape } from "../rules"; +import { splitTiles } from "../rules"; + +/** Stable key for one algorithm×product pair. */ +export function productKey(p: { algorithm: string; product: string }): string { + return `${p.algorithm}:${p.product}`; +} + +export interface RequestDraft { + // --- event (all strings, straight off the inputs) --- + name: string; // human-readable, e.g. "California Wildfires January 2025" + stacName: string; // YYYYMM_Hazard_Location, e.g. "202501_Fire_CA" + start: string; // YYYY-MM-DD + end: string; // YYYY-MM-DD + bbox: string; // "min_lon,min_lat,max_lon,max_lat" — optional + mgrsTiles: string; // optional, space-separated Sentinel-2 MGRS tiles, e.g. "T17RLN T17RLM" + wrs2Tiles: string; // optional, space-separated Landsat WRS-2 path/rows, e.g. "171035" + requester: string; // optional + notes: string; // optional + // --- repeatable / multi-select --- + hazards: string[]; // Hazard["id"] + locations: string[]; // free text rows; always at least one (possibly empty) row + products: RequestProduct[]; // resolved list: hazard-derived (via:"hazard") + hand-picked (via:"manual") + /** productKey()s of hazard-derived products the user removed, so they don't reappear. Draft-only. */ + dismissed: string[]; +} + +export function newDraft(): RequestDraft { + return { + name: "", + stacName: "", + start: "", + end: "", + bbox: "", + mgrsTiles: "", + wrs2Tiles: "", + requester: "", + notes: "", + hazards: [], + locations: [""], + products: [], + dismissed: [], + }; +} + +export function slugify(s: string): string { + return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "request"; +} + +/** The id an AlgorithmRequest gets — also the upsert key in the requests cart. */ +export function draftId(d: RequestDraft): string { + return slugify(d.stacName || d.name); +} + +/** Trimmed, empty rows dropped. */ +export function cleanLocations(locations: string[]): string[] { + return locations.map((l) => l.trim()).filter(Boolean); +} + +/** What src/rules.ts validates. Kept separate so the form can validate WITHOUT building a record. */ +export function draftToShape(d: RequestDraft): RequestShape { + return { + name: d.name, + stacName: d.stacName, + start: d.start, + end: d.end, + hazards: d.hazards, + locations: cleanLocations(d.locations), + bbox: d.bbox, + mgrsTiles: d.mgrsTiles, + wrs2Tiles: d.wrs2Tiles, + products: d.products.map((p) => ({ algorithm: p.algorithm, product: p.product })), + }; +} + +/** `ts` is passed in (not generated) so this stays pure and the JSON preview is stable per render. */ +export function draftToRequest(d: RequestDraft, ts: string): AlgorithmRequest { + const r: AlgorithmRequest = { + kind: "algorithm-request", + id: draftId(d), + ts, + event: { + name: d.name.trim(), + stacName: d.stacName.trim(), + start: d.start, + end: d.end, + hazards: [...d.hazards], + locations: cleanLocations(d.locations), + }, + products: d.products.map((p) => ({ ...p })), + }; + if (d.bbox.trim()) r.event.bbox = d.bbox.trim(); + const mgrs = splitTiles(d.mgrsTiles); + const wrs2 = splitTiles(d.wrs2Tiles); + if (mgrs.length || wrs2.length) { + r.event.tiles = {}; + if (mgrs.length) r.event.tiles.mgrs = mgrs; + if (wrs2.length) r.event.tiles.wrs2 = wrs2; + } + if (d.requester.trim()) r.requester = d.requester.trim(); + if (d.notes.trim()) r.notes = d.notes.trim(); + return r; +} + +/** Reverse: prefill the form from a staged request (edit-from-cart). */ +export function requestToDraft(r: AlgorithmRequest): RequestDraft { + const d = newDraft(); + d.name = r.event.name; + d.stacName = r.event.stacName; + d.start = r.event.start; + d.end = r.event.end; + d.bbox = r.event.bbox ?? ""; + d.mgrsTiles = (r.event.tiles?.mgrs ?? []).join(" "); + d.wrs2Tiles = (r.event.tiles?.wrs2 ?? []).join(" "); + d.requester = r.requester ?? ""; + d.notes = r.notes ?? ""; + d.hazards = [...r.event.hazards]; + d.locations = r.event.locations.length > 0 ? [...r.event.locations] : [""]; + d.products = r.products.map((p) => ({ ...p })); + return d; +} + +// --------------------------------------------------------------------------------------------- +// Hazard -> product derivation +// +// Product["hazards"] is the hand-built suitability mapping in data/algorithms.json. Picking a +// hazard auto-selects every product that claims it; the user can drop any of them (remembered in +// `dismissed`) and add anything the mapping missed via the picker (those come back as "manual"). +// --------------------------------------------------------------------------------------------- + +/** Every product whose suitability mapping mentions one of the selected hazards, in catalog order. */ +export function autoProducts(algorithms: Algorithm[], hazardIds: string[]): RequestProduct[] { + if (hazardIds.length === 0) return []; + const want = new Set(hazardIds); + const out: RequestProduct[] = []; + for (const a of algorithms) { + // Prototype algorithms have no DPS job — never propose them automatically; the user can + // still add them by hand from the picker if they really want manual processing. + if (a.status === "prototype") continue; + for (const p of a.products) { + // primaryHazards, NOT hazards. `hazards` is the broad discovery set (every product that + // could conceivably help); auto-selecting from it produced ~28 chips for a single hazard, + // which is noise, not a starting point. + if (p.primaryHazards.some((h) => want.has(h))) { + out.push({ algorithm: a.id, product: p.id, via: "hazard" }); + } + } + } + return out; +} + +/** + * Recompute the resolved product list after a hazard change. + * Manual picks always survive; hazard-derived picks follow the hazard selection minus `dismissed`. + */ +export function syncProducts( + current: RequestProduct[], + algorithms: Algorithm[], + hazardIds: string[], + dismissed: string[], +): RequestProduct[] { + const manual = current.filter((p) => p.via === "manual"); + const manualKeys = new Set(manual.map(productKey)); + const skip = new Set(dismissed); + const auto = autoProducts(algorithms, hazardIds).filter( + (p) => !manualKeys.has(productKey(p)) && !skip.has(productKey(p)), + ); + return [...auto, ...manual]; +} diff --git a/algorithm-catalog/src/requests.ts b/algorithm-catalog/src/requests.ts new file mode 100644 index 0000000..16812a8 --- /dev/null +++ b/algorithm-catalog/src/requests.ts @@ -0,0 +1,51 @@ +// Turning staged requests into ONE prefilled-PR file. +// +// GitHub's `new/?filename=&value=` route creates a single new file on a fresh branch and +// offers a PR — no backend, no OAuth, no token. Every staged request goes into that one file, so +// one trip to GitHub = one PR, and the filename is unique (timestamp-stamped) so two people +// submitting at once never conflict. + +import type { AlgorithmRequest } from "./types"; +import { slugify } from "./request/drafts"; + +export const REPO = "NASA-IMPACT/veda-github-actions"; +export const BASE_BRANCH = "main"; + +/** + * How long a prefilled-PR URL may get before GitHub (and the browser) choke on it. + * + * cost-dashboard uses the same 190000; dse-hub's ChangesCart uses 7500, which is far too small + * here — one request over a broad hazard set can carry 40+ products and blow past 7500 on its own. + * Above the limit the PR button is disabled and the user is told to use "Copy JSON" instead. + */ +export const URL_LIMIT = 190000; + +/** Directory the compactor folds back into the canonical data. Repo-root relative. */ +export const REQUEST_DIR = "algorithm-catalog/data/requests"; + +/** + * Build the filename + JSON body for a bundle of staged requests. + * + * Filename is REPO-ROOT relative (that is what the GitHub `new/` route wants) — do NOT build it in + * request/drafts.ts; dse-hub grew a vestigial second path constant by doing exactly that. + * Stamp = the newest `ts` in the bundle with every non-alphanumeric replaced by "-", which keeps + * the name sortable, unique, and safe on every filesystem. + */ +export function buildRequestFile(items: AlgorithmRequest[]): { filename: string; json: string } { + const maxTs = items.reduce((m, r) => (r.ts > m ? r.ts : m), ""); + const stamp = maxTs.replace(/[^a-zA-Z0-9]/g, "-"); + const newest = items.find((r) => r.ts === maxTs) ?? items[0]; + const base = newest ? slugify(newest.event.stacName || newest.event.name) : "request"; + const slug = items.length > 1 ? `${base}-plus-${items.length - 1}` : base; + return { + filename: `${REQUEST_DIR}/${stamp}-${slug}.json`, + json: JSON.stringify(items, null, 2), + }; +} + +/** The prefilled "create a new file" URL. `path` is repo-root relative. */ +export function ghNewFileUrl(path: string, content: string): string { + return `https://github.com/${REPO}/new/${BASE_BRANCH}?filename=${encodeURIComponent( + path, + )}&value=${encodeURIComponent(content)}`; +} diff --git a/algorithm-catalog/src/rules.ts b/algorithm-catalog/src/rules.ts new file mode 100644 index 0000000..d996706 --- /dev/null +++ b/algorithm-catalog/src/rules.ts @@ -0,0 +1,361 @@ +// THE STANDARD. Defined once here, mirrored 1:1 in scripts/validate_data.py, and kept honest by +// scripts/rules_parity_test.py (which runs the same fixture table through both copies). +// +// Every rule below is lifted from the real enforcement point in +// Disasters-Learning-Portal/disasters-product-algorithms @ dev, so this app rejects exactly what +// DPS rejects at run time — but while you type, instead of 40 minutes into a job. +// +// If you change a rule here you MUST change it in scripts/validate_data.py too; the parity test +// fails otherwise, and CI runs the parity test. + +import type { Hazard } from "./types"; + +// --------------------------------------------------------------------------------------------- +// Source-of-truth constants +// --------------------------------------------------------------------------------------------- + +/** + * YYYYMM_Hazard_Location — EXACTLY three parts, so exactly two underscores. + * + * DELIBERATELY STRICTER THAN UPSTREAM. dps/_validate.sh:29-36 uses + * ^[0-9]{4}(0[1-9]|1[0-2])_[^_]+_.+$ + * whose final `.+` lets the LOCATION slot swallow extra underscores. That accepts + * `202501_Tropical_Cyclone_CA` — which looks right but silently parses as + * hazard=`Tropical`, location=`Cyclone_CA`, and then writes a GeoTIFF `HAZARD` tag of + * "Tropical". It also accepts `202501_Flood_CA_extra` (an explicit upstream pass case, see + * tests/integration/test_dps_validate.sh:41-48). + * + * The catalog rejects both: the third slot is `[^_]+` too. This is the safe direction to + * diverge — every name WE accept, DPS also accepts. We only refuse some names DPS would have + * taken, and those are precisely the ones that mis-slot the hazard. Multi-word hazards and + * locations must be CamelCase: 202604_TropicalCyclone_Guam, not 202604_Tropical_Cyclone_Guam. + */ +export const STAC_EVENT_RE = /^[0-9]{4}(0[1-9]|1[0-2])_[^_]+_[^_]+$/; + +/** Exactly three underscore-separated slots: YYYYMM, Hazard, Location. */ +export const STAC_PART_COUNT = 3; + +/** The literal placeholder the upstream configs ship with. Rejected at run time. */ +export const STAC_PLACEHOLDER = "YYYYMM_Hazard_Location"; + +/** dps/_validate.sh:61-76 — validate_date_not_before() date shape. */ +export const ISO_DATE_RE = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; + +/** dps/_validate.sh:118-143 — validate_bbox(). blackmarble.crs rejects a thinner box. */ +export const BBOX_MIN_LAT_SPAN = 0.05; + +/** A worked example to show the user in every error message about the event name. */ +export const STAC_EXAMPLE = "202511_Flood_TX"; + +// --------------------------------------------------------------------------------------------- +// Violation type +// --------------------------------------------------------------------------------------------- + +export type Severity = "error" | "warning"; + +export interface Violation { + field: string; + rule: string; + severity: Severity; + message: string; +} + +const err = (field: string, rule: string, message: string): Violation => ({ + field, + rule, + severity: "error", + message, +}); + +const warn = (field: string, rule: string, message: string): Violation => ({ + field, + rule, + severity: "warning", + message, +}); + +// --------------------------------------------------------------------------------------------- +// Individual rules +// --------------------------------------------------------------------------------------------- + +/** + * Split a STAC event name into its three slots, or null if it isn't exactly three. + * + * Note this does NOT mirror upstream cog_metadata.parse_activation_event(), which does a + * `split('_', 2)` so LOCATION absorbs every trailing underscore. That absorption is the bug we + * are closing — see STAC_EVENT_RE. + */ +export function parseStacName(v: string): { yearMonth: string; hazard: string; location: string } | null { + const parts = v.split("_"); + if (parts.length !== STAC_PART_COUNT) return null; + return { yearMonth: parts[0], hazard: parts[1], location: parts[2] }; +} + +/** + * The headline check: STAC metadata event name must be YYYYMM_Hazard_Location. + * `hazards` (optional) enables the soft cross-check that the token matches the hazards selected + * on the form — the regex permits any token, but a mismatch is nearly always a mistake. + */ +export function checkStacName(value: string, hazards: Hazard[] = [], selected: string[] = []): Violation[] { + const v = value.trim(); + const out: Violation[] = []; + + if (!v) { + out.push(err("stacName", "required", "STAC metadata event name is required.")); + return out; + } + if (v === STAC_PLACEHOLDER) { + out.push( + err( + "stacName", + "placeholder", + `"${STAC_PLACEHOLDER}" is the placeholder value and is rejected at run time. Set a real event, e.g. ${STAC_EXAMPLE}.`, + ), + ); + return out; + } + if (!STAC_EVENT_RE.test(v)) { + // Say precisely which part is wrong — a bare "doesn't match" is useless to a responder. + // Underscore count is checked FIRST and reported on its own, because it is the mistake + // people actually make (writing a multi-word hazard or location in full). + const parts = v.split("_"); + const underscores = parts.length - 1; + + if (underscores < 2) { + out.push( + err( + "stacName", + "underscore-count", + `Needs exactly 2 underscores (YYYYMM_Hazard_Location) — found ${underscores}. Example: ${STAC_EXAMPLE}.`, + ), + ); + } else if (underscores > 2) { + // The dangerous one. Show them what it would silently be read as. + out.push( + err( + "stacName", + "underscore-count", + `Needs exactly 2 underscores (YYYYMM_Hazard_Location) — found ${underscores}. ` + + `"${v}" would be read as hazard "${parts[1]}", location "${parts.slice(2).join("_")}". ` + + `Run multi-word names together in CamelCase, e.g. ${STAC_EXAMPLE} or 202604_TropicalCyclone_Guam.`, + ), + ); + } else if (!/^[0-9]{6}$/.test(parts[0])) { + out.push(err("stacName", "yearmonth", `"${parts[0]}" must be a 6-digit YYYYMM, e.g. 202511.`)); + } else if (!/^(0[1-9]|1[0-2])$/.test(parts[0].slice(4))) { + out.push( + err("stacName", "month", `Month "${parts[0].slice(4)}" is not 01-12. Example: ${STAC_EXAMPLE}.`), + ); + } else { + out.push(err("stacName", "format", `Must be YYYYMM_Hazard_Location, e.g. ${STAC_EXAMPLE}.`)); + } + return out; + } + + const parsed = parseStacName(v)!; + + if (hazards.length > 0) { + const known = hazards.some( + (h) => + h.token.toLowerCase() === parsed.hazard.toLowerCase() || + h.aliases.some((a) => a.toLowerCase() === parsed.hazard.toLowerCase()), + ); + if (!known) { + out.push( + warn( + "stacName", + "hazard-vocabulary", + `Hazard token "${parsed.hazard}" is not in the controlled vocabulary. Known tokens: ${hazards + .map((h) => h.token) + .join(", ")}.`, + ), + ); + } else if (selected.length > 0) { + const match = hazards.find( + (h) => + h.token.toLowerCase() === parsed.hazard.toLowerCase() || + h.aliases.some((a) => a.toLowerCase() === parsed.hazard.toLowerCase()), + ); + if (match && !selected.includes(match.id)) { + out.push( + warn( + "stacName", + "hazard-mismatch", + `Event name says "${parsed.hazard}" but that hazard is not selected below.`, + ), + ); + } + } + } + + return out; +} + +/** ISO shape + ordering. Upstream only checks shape; start<=end is our own catalog invariant. */ +export function checkDates(start: string, end: string): Violation[] { + const out: Violation[] = []; + if (!start) out.push(err("start", "required", "Start date is required.")); + else if (!ISO_DATE_RE.test(start)) + out.push(err("start", "format", `Start date "${start}" must be YYYY-MM-DD.`)); + + if (!end) out.push(err("end", "required", "End date is required.")); + else if (!ISO_DATE_RE.test(end)) out.push(err("end", "format", `End date "${end}" must be YYYY-MM-DD.`)); + + if (start && end && ISO_DATE_RE.test(start) && ISO_DATE_RE.test(end) && start > end) { + out.push(err("end", "order", `End date ${end} is before start date ${start}.`)); + } + return out; +} + +/** dps/_validate.sh:118-143 — validate_bbox(), rule for rule. Empty is allowed (bbox is optional). */ +export function checkBbox(value: string): Violation[] { + const v = value.trim(); + if (!v) return []; + const parts = v.split(/[,\s]+/).filter(Boolean); + if (parts.length !== 4) { + return [ + err("bbox", "count", `Bounding box needs exactly 4 numbers "min_lon,min_lat,max_lon,max_lat"; got ${parts.length}.`), + ]; + } + if (!parts.every((p) => /^[-+]?[0-9]*\.?[0-9]+$/.test(p))) { + return [err("bbox", "numeric", `Bounding box must be four numbers "min_lon,min_lat,max_lon,max_lat" (WGS84).`)]; + } + const [minLon, minLat, maxLon, maxLat] = parts.map(Number); + const out: Violation[] = []; + if (minLon < -180 || maxLon > 180 || minLat < -90 || maxLat > 90) { + out.push(err("bbox", "range", "Bounding box is out of range (lon in [-180,180], lat in [-90,90]).")); + return out; + } + if (minLon >= maxLon || minLat >= maxLat) { + out.push(err("bbox", "order", "Bounding box needs min_lon v.trim().split(/[\s,]+/).filter(Boolean); + +export function checkTiles(mgrs: string, wrs2: string): Violation[] { + const out: Violation[] = []; + for (const t of splitTiles(mgrs)) { + if (!MGRS_TILE_RE.test(t)) { + out.push( + err( + "mgrsTiles", + "mgrs-format", + `"${t}" is not an MGRS tile. Expected T + 2 digits + 3 letters, e.g. T17RLN. Separate several with spaces.`, + ), + ); + } + } + for (const t of splitTiles(wrs2)) { + if (!WRS2_TILE_RE.test(t)) { + out.push( + err( + "wrs2Tiles", + "wrs2-format", + `"${t}" is not a WRS-2 path/row. Expected exactly 6 digits (3-digit path + 3-digit row), e.g. 171035.`, + ), + ); + } + } + return out; +} + +export function checkHazards(selected: string[], vocabulary: Hazard[]): Violation[] { + const out: Violation[] = []; + if (selected.length === 0) { + out.push(err("hazards", "required", "Select at least one hazard type.")); + return out; + } + const ids = new Set(vocabulary.map((h) => h.id)); + for (const h of selected) { + if (!ids.has(h)) out.push(err("hazards", "vocabulary", `"${h}" is not a known hazard type.`)); + if (h.includes("_")) out.push(err("hazards", "underscore", `Hazard "${h}" must not contain an underscore.`)); + } + return out; +} + +export function checkLocations(locations: string[]): Violation[] { + const clean = locations.map((l) => l.trim()).filter(Boolean); + if (clean.length === 0) { + return [err("locations", "required", "Add at least one location, e.g. Los Angeles, CA.")]; + } + return []; +} + +export function checkName(name: string): Violation[] { + if (!name.trim()) { + return [ + err( + "name", + "required", + "Activation event name is required — this is the human-readable name shown on the website, e.g. California Wildfires January 2025.", + ), + ]; + } + return []; +} + +export function checkProducts(products: { algorithm: string; product: string }[]): Violation[] { + if (products.length === 0) { + return [err("products", "required", "Select at least one product to generate.")]; + } + return []; +} + +// --------------------------------------------------------------------------------------------- +// Whole-request validation — what gates the "Open PR" button +// --------------------------------------------------------------------------------------------- + +export interface RequestShape { + name: string; + stacName: string; + start: string; + end: string; + hazards: string[]; + locations: string[]; + bbox: string; + mgrsTiles: string; + wrs2Tiles: string; + products: { algorithm: string; product: string }[]; +} + +export function validateRequest(r: RequestShape, vocabulary: Hazard[]): Violation[] { + return [ + ...checkName(r.name), + ...checkStacName(r.stacName, vocabulary, r.hazards), + ...checkDates(r.start, r.end), + ...checkHazards(r.hazards, vocabulary), + ...checkLocations(r.locations), + ...checkBbox(r.bbox), + ...checkTiles(r.mgrsTiles, r.wrs2Tiles), + ...checkProducts(r.products), + ]; +} + +export const errorsOnly = (v: Violation[]): Violation[] => v.filter((x) => x.severity === "error"); + +/** The gate. Warnings never block — only errors do. */ +export const isSubmittable = (v: Violation[]): boolean => errorsOnly(v).length === 0; + +/** Violations for one field, for rendering inline under the input. */ +export const forField = (v: Violation[], field: string): Violation[] => v.filter((x) => x.field === field); diff --git a/algorithm-catalog/src/styles.css b/algorithm-catalog/src/styles.css new file mode 100644 index 0000000..3de6a15 --- /dev/null +++ b/algorithm-catalog/src/styles.css @@ -0,0 +1,485 @@ +/* ============================================================================================ + * Algorithm Catalog — the whole stylesheet, minus the request form (src/request.css). + * + * Theming is HDS (NASA Horizon Design System) laid over the USWDS skeleton the sibling dashboards + * use: NASA brand blue/red for anything branded, USWDS greys and state colors for everything + * structural. LIGHT ONLY — there is deliberately no [data-theme] block and no no-FOUC script in + * index.html. It is still structured the way the themed dashboards are (every color is a custom + * property on :root, nothing hardcodes a hex outside this block), so a dark theme could be layered + * later by adding a single :root[data-theme="dark"] override — no component would need to change. + * + * The one thing that must NOT be themed is data color. Hazard colors come from data/hazards.json + * and modality accents from src/colors.ts; both carry meaning and are applied inline. House rule: + * "Only chrome adapts. Data colors stay fixed." + * ============================================================================================ */ + +:root { + /* --- Brand: NASA (HDS) ------------------------------------------------------------------ */ + --primary: #0b3d91; /* NASA blue */ + --primary-dark: #061f4a; + --primary-darker: #041534; + --primary-light: #105bd8; + --primary-lighter: #d9e8f6; + --secondary: #dd361c; /* NASA red — active-tab underline, badges, never body text */ + + /* --- USWDS greys, kept verbatim from the sibling dashboards ------------------------------ */ + --ink: #1b1b1b; + --base-dark: #3d4551; + --base: #565c65; + --base-mid: #71767a; + --base-light: #a9aeb1; + --base-lighter: #dfe1e2; + --base-lightest: #f0f0f0; + --white: #ffffff; + --bg: #f4f6f9; + + /* --- State ------------------------------------------------------------------------------- */ + --success: #00a91c; + --warning: #ffbe2e; + --error: #d54309; + + /* --- Shape ------------------------------------------------------------------------------- */ + --radius: 8px; + --shadow: 0 1px 2px rgba(27, 27, 27, 0.08), 0 6px 18px rgba(27, 27, 27, 0.08); + --shadow-lg: 0 12px 40px rgba(27, 27, 27, 0.18); + + /* --- Type: HDS --------------------------------------------------------------------------- + No @font-face and no Google Fonts . This repo's habit is zero external dependencies at + runtime, and a CDN font would make the app fail differently offline than online. Inter / + Public Sans / DM Mono are used when the reader has them; otherwise each stack falls all the + way through to the platform UI font, which is what most readers will actually see. */ + --font-display: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "DM Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; } + +body { + margin: 0; + font-family: var(--font); + color: var(--ink); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { font-family: var(--font-display); letter-spacing: -0.01em; } + +code, pre { font-family: var(--font-mono); } + +a { color: var(--primary); } + +/* One visible focus ring for the whole app. Never removed — only replaced with this. */ +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; + box-shadow: 0 0 0 3px var(--primary-lighter); +} + +.app { min-height: 100%; display: flex; flex-direction: column; } + +/* ============ Header ============ */ +.hub-header { + display: flex; align-items: center; gap: 1.5rem; + padding: 0 1.25rem; height: 56px; + background: var(--primary-dark); color: var(--white); +} +.hub-brand { display: flex; align-items: center; gap: 0.45rem; font-weight: 700; flex-shrink: 0; } +.hub-logo { + background: var(--white); color: var(--primary); + padding: 0.05rem 0.4rem; border-radius: 5px; font-size: 1rem; line-height: 1.5; +} +.hub-title { font-family: var(--font-display); font-size: 1.05rem; letter-spacing: 0.01em; } + +.hub-tabs { display: flex; gap: 0.15rem; height: 100%; flex: 1; } +.hub-tabs button { + background: transparent; border: 0; color: rgba(255, 255, 255, 0.82); + font: inherit; font-size: 0.95rem; padding: 0 0.9rem; cursor: pointer; + border-bottom: 3px solid transparent; display: flex; align-items: center; gap: 0.4rem; +} +.hub-tabs button:hover { color: var(--white); background: rgba(255, 255, 255, 0.07); } +.hub-tabs button.active { color: var(--white); border-bottom-color: var(--secondary); font-weight: 600; } +.tab-icon { font-size: 0.95rem; } + +.hub-right { display: flex; align-items: center; gap: 0.4rem; margin-left: auto; flex-shrink: 0; } +.cart-btn { + background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.28); + color: rgba(255, 255, 255, 0.92); font: inherit; font-size: 0.85rem; + padding: 0.28rem 0.65rem; border-radius: 5px; cursor: pointer; + display: inline-flex; align-items: center; gap: 0.4rem; white-space: nowrap; +} +.cart-btn:hover { background: rgba(255, 255, 255, 0.22); border-color: rgba(255, 255, 255, 0.55); } +.cart-badge { + background: var(--secondary); color: var(--white); + font-size: 0.72rem; font-weight: 700; padding: 0.05rem 0.4rem; border-radius: 20px; line-height: 1.5; +} + +.hub-main { flex: 1; padding: 1.25rem; max-width: 1280px; width: 100%; margin: 0 auto; } + +/* ============ House controls (shared with request.css) ============ */ +.toolbar { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; } + +.search { + position: relative; display: flex; align-items: center; flex: 1 1 300px; min-width: 240px; + background: var(--white); border: 1px solid var(--base-light); border-radius: var(--radius); + padding: 0 0.6rem; height: 40px; +} +.search:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-lighter); } +.search-ico { opacity: 0.5; font-size: 0.9rem; } +.search input { + flex: 1; border: 0; outline: 0; font: inherit; font-size: 0.95rem; + padding: 0 0.5rem; background: transparent; color: inherit; +} +.search-x { border: 0; background: transparent; cursor: pointer; font-size: 1.2rem; color: var(--base); line-height: 1; } + +.seg { display: inline-flex; background: var(--base-lightest); border-radius: var(--radius); padding: 3px; } +.seg button { + border: 0; background: transparent; font: inherit; font-size: 0.9rem; padding: 0.45rem 0.9rem; + border-radius: 6px; cursor: pointer; color: var(--base); +} +.seg button.active { background: var(--white); color: var(--primary); box-shadow: var(--shadow); font-weight: 600; } + +.btn { + border: 1px solid var(--base-light); background: var(--white); color: var(--ink); + font: inherit; font-size: 0.9rem; padding: 0.5rem 0.85rem; border-radius: var(--radius); + cursor: pointer; height: 40px; display: inline-flex; align-items: center; gap: 0.35rem; + white-space: nowrap; text-decoration: none; +} +.btn:hover { border-color: var(--base); } +.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--white); font-weight: 600; } +.btn.primary:hover { background: var(--primary-darker); border-color: var(--primary-darker); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +/* A picker whose selection is a strict subset — i.e. it is actually filtering something out. */ +.btn.narrowed { border-color: var(--primary); background: var(--primary-lighter); color: var(--primary-dark); font-weight: 600; } +.reset-btn { margin-left: auto; } + +.panel { background: var(--white); border: 1px solid var(--base-lighter); border-radius: var(--radius); box-shadow: var(--shadow); } + +.control { position: relative; } +.popover { + position: absolute; z-index: 30; top: 44px; left: 0; min-width: 230px; max-height: 340px; + overflow: auto; padding: 0.4rem; +} +.popover .pop-search { + width: 100%; font: inherit; font-size: 0.88rem; padding: 0.35rem 0.5rem; margin-bottom: 0.35rem; + border: 1px solid var(--base-light); border-radius: 6px; background: var(--white); color: inherit; +} +.popover .pop-search:focus { outline: 0; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-lighter); } +.popover .actions { display: flex; gap: 0.4rem; margin-bottom: 0.3rem; } +.popover .actions button { + flex: 1; border: 1px solid var(--base-lighter); background: var(--base-lightest); + border-radius: 6px; padding: 0.3rem; font: inherit; font-size: 0.8rem; cursor: pointer; color: var(--base-dark); +} +.popover .actions button:hover { border-color: var(--base-light); } +.popover .row { + display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0.35rem; + border-radius: 6px; cursor: pointer; font-size: 0.9rem; +} +.popover .row:hover { background: var(--base-lightest); } +.pop-group { border-top: 1px solid var(--base-lighter); margin-top: 0.25rem; padding-top: 0.25rem; } +.pop-group:first-of-type { border-top: 0; margin-top: 0; padding-top: 0; } +.row-group-name { font-weight: 700; } +.row-child { padding-left: 1.3rem; } +.pop-empty { margin: 0.4rem 0.35rem; font-size: 0.85rem; color: var(--base-light); } +.sw { width: 12px; height: 12px; border-radius: 3px; display: inline-block; flex-shrink: 0; } + +.field { margin-bottom: 0.7rem; } +.field.two { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; } +.field label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--base-dark); margin-bottom: 0.2rem; } +.field input, .field select, .field textarea { + width: 100%; font: inherit; font-size: 0.9rem; padding: 0.45rem 0.55rem; + border: 1px solid var(--base-light); border-radius: 6px; background: var(--white); color: inherit; +} +.field input:focus, .field select:focus, .field textarea:focus { + outline: 0; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-lighter); +} +.opt { color: var(--base-light); font-weight: 400; } + +.hint { + font-size: 0.82rem; color: var(--base); background: var(--base-lightest); + padding: 0.55rem 0.7rem; border-radius: 6px; margin: 0.5rem 0 0; line-height: 1.45; +} +.hint code, .jsonlabel code { + background: var(--white); padding: 0.05rem 0.3rem; border-radius: 4px; + border: 1px solid var(--base-lighter); font-size: 0.85em; +} +.linkbtn { + border: 0; background: transparent; color: var(--primary); text-decoration: underline; + cursor: pointer; font: inherit; font-size: 0.82rem; padding: 0; +} +.count { font-variant-numeric: tabular-nums; color: var(--base); } +.empty { color: var(--base); padding: 2.5rem 1rem; text-align: center; max-width: 42rem; margin: 0 auto; } + +/* Remove-a-row button (request form repeatables). */ +.rm { + border: 1px solid var(--base-light); background: var(--white); color: var(--error); + width: 34px; height: 38px; border-radius: 6px; cursor: pointer; font-size: 1.15rem; + line-height: 1; flex: 0 0 auto; +} +.rm:hover { background: #fdecea; border-color: var(--error); } + +/* ============ Overlay / modal ============ */ +.overlay { + position: fixed; inset: 0; background: rgba(4, 21, 52, 0.5); + display: flex; align-items: flex-start; justify-content: center; + padding: 3rem 1rem; z-index: 100; overflow: auto; +} +.modal { max-width: 640px; width: 100%; padding: 1.1rem 1.2rem; max-height: 88vh; overflow: auto; } +.modal.wide { max-width: 860px; } +.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; } +.modal-head h2 { margin: 0; font-size: 1.25rem; } +.modal-x { + border: 0; background: transparent; font-size: 1.6rem; line-height: 1; + cursor: pointer; color: var(--base); flex-shrink: 0; +} +.modal-x:hover { color: var(--ink); } + +.jsonlabel { display: block; font-size: 0.78rem; color: var(--base); margin-bottom: 0.25rem; } +.urlbox { + background: var(--primary-darker); color: #cfe2ff; border-radius: 6px; padding: 0.6rem 0.7rem; + font-size: 0.75rem; max-height: 200px; overflow: auto; margin: 0 0 0.9rem; + white-space: pre-wrap; word-break: break-word; +} +.foot { display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap; margin-top: 1rem; } + +/* ============ Algorithms: entry modes + filters ============ */ +.mode-row { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; margin-bottom: 0.9rem; } +.mode-note { margin: 0; font-size: 0.85rem; color: var(--base); flex: 1 1 320px; } + +.filterbar { display: flex; flex-direction: column; gap: 0.7rem; } + +.pillrow { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.hpill { + border: 1px solid var(--base-light); border-left-width: 4px; background: var(--white); + color: var(--base-dark); font: inherit; font-size: 0.85rem; padding: 0.3rem 0.7rem; + border-radius: 20px; cursor: pointer; display: inline-flex; align-items: center; gap: 0.35rem; +} +.hpill:hover { box-shadow: var(--shadow); } +.hpill.on { color: var(--white); font-weight: 600; border-left-width: 1px; } + +.context { + display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; padding: 0.9rem 1rem; +} +.context-col { display: flex; flex-direction: column; } +.context-col .field { margin-bottom: 0.35rem; } +.daterange .field.two { margin-bottom: 0.35rem; } + +.chosen { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.hchip { + display: inline-flex; align-items: center; gap: 0.32rem; color: var(--white); + font-size: 0.82rem; font-weight: 600; padding: 0.2rem 0.6rem; border-radius: 20px; + line-height: 1.4; +} +.hchip.sm { font-size: 0.74rem; padding: 0.12rem 0.48rem; font-weight: 500; } +/* The "+N more" counter is chrome, not a hazard, so it takes grey rather than a hazard color. */ +.hchip.more { background: var(--base-lightest); color: var(--base-dark); font-weight: 600; } +.hchip-x { + border: 0; background: transparent; color: inherit; cursor: pointer; + font-size: 1rem; line-height: 1; padding: 0 0 0 0.15rem; opacity: 0.8; +} +.hchip-x:hover { opacity: 1; } + +.used-flag { + background: var(--secondary); color: var(--white); font-size: 0.68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.03em; padding: 0.08rem 0.4rem; border-radius: 4px; + white-space: nowrap; +} + +.count-strip { + display: flex; align-items: center; gap: 0.8rem; flex-wrap: wrap; + margin: 1rem 0 0.7rem; font-size: 0.85rem; color: var(--base); +} +.count-note { display: inline-flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; } + +/* ============ Algorithm cards ============ */ +.agrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 0.9rem; } + +.acard { + display: flex; flex-direction: column; text-align: left; font: inherit; color: inherit; + background: var(--white); border: 1px solid var(--base-lighter); border-left: 4px solid var(--base-light); + border-radius: var(--radius); box-shadow: var(--shadow); cursor: pointer; overflow: hidden; + padding: 0; transition: box-shadow 0.15s, transform 0.15s; +} +.acard:hover { box-shadow: var(--shadow-lg); transform: translateY(-1px); } + +.acard-thumb-wrap { position: relative; } +.acard-thumb { display: block; width: 100%; height: 132px; object-fit: cover; background: var(--base-lightest); } +/* The fallback when public/thumbs/.png is missing — a flat modality block, never a broken image. */ +.acard-thumb-fallback { display: flex; align-items: center; justify-content: center; font-size: 2.4rem; } +.acard-used { position: absolute; top: 0.5rem; right: 0.5rem; } + +.acard-body { padding: 0.8rem 0.95rem 0.9rem; display: flex; flex-direction: column; gap: 0.5rem; flex: 1; } +.acard-top { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; } +.acard-title { margin: 0; font-size: 1.1rem; line-height: 1.25; flex: 1 1 auto; } +.acard-vendor { margin: 0; font-size: 0.85rem; color: var(--base); } + +/* The description is the card's substance, so it gets body-copy sizing and near-body color — not + the micro-copy grey used for labels. Clamped to 3 lines to keep the grid rows even; the full + text is one click away in AlgorithmDetail (and in the title attribute). */ +.acard-desc { + margin: 0; font-size: 0.9rem; line-height: 1.45; color: var(--base-dark); + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; + overflow: hidden; +} + +/* A labelled block inside the card ("Suited to", "Products") — the icon rows were unreadable + without a heading saying what they were. */ +.acard-group { display: flex; flex-direction: column; gap: 0.28rem; } +.acard-group-label { + font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--base-mid); +} +.acard-body .acard-group:last-child { margin-top: auto; padding-top: 0.1rem; } + +.mbadge { + display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.7rem; font-weight: 700; + padding: 0.12rem 0.45rem; border-radius: 20px; white-space: nowrap; +} +.proto-badge { + background: var(--warning); color: #3d2600; font-size: 0.68rem; font-weight: 700; + padding: 0.1rem 0.4rem; border-radius: 4px; white-space: nowrap; +} +.prod-badge { + background: var(--success); color: var(--white); font-size: 0.68rem; font-weight: 700; + padding: 0.1rem 0.4rem; border-radius: 4px; +} +.vendor-badge { + background: var(--base-lightest); color: var(--base-dark); font-size: 0.75rem; + padding: 0.12rem 0.5rem; border-radius: 20px; +} + +.acard-facts { display: flex; gap: 1.4rem; margin: 0; } +.acard-facts dt { font-size: 0.68rem; color: var(--base-mid); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; } +.acard-facts dd { margin: 0.05rem 0 0; font-size: 0.92rem; color: var(--ink); font-variant-numeric: tabular-nums; } + +.acard-hazards { display: flex; flex-wrap: wrap; gap: 0.3rem; } + +.acard-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.pchip { + background: var(--base-lightest); color: var(--base-dark); border: 1px solid var(--base-lighter); + font-size: 0.8rem; padding: 0.15rem 0.5rem; border-radius: 4px; line-height: 1.4; +} +.pchip.more { background: transparent; border-style: dashed; color: var(--base); } +.pchip.none { background: transparent; border: 0; color: var(--base-light); font-style: italic; } + +/* ============ Algorithm detail ============ */ +.detail-badges { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.7rem 0; } +.detail-desc { margin: 0.6rem 0 1rem; font-size: 0.95rem; line-height: 1.5; color: var(--base-dark); } +.detail-grid { + display: grid; grid-template-columns: 140px 1fr; gap: 0.3rem 0.8rem; + margin: 0 0 1.1rem; font-size: 0.88rem; +} +.detail-grid dt { color: var(--base); font-weight: 600; } +.detail-grid dd { margin: 0; } +.detail-h { + margin: 1.2rem 0 0.5rem; font-size: 1rem; color: var(--primary-dark); + border-bottom: 1px solid var(--base-lighter); padding-bottom: 0.3rem; +} + +.prod-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.45rem; } +.prod { + border: 1px solid var(--base-lighter); border-left: 3px solid var(--base-lighter); + border-radius: 6px; padding: 0.5rem 0.65rem; +} +.prod.matched { border-left-color: var(--primary); background: #f7fafd; } +.prod-head { display: flex; align-items: baseline; gap: 0.45rem; flex-wrap: wrap; font-size: 0.9rem; } +.prod-head code { font-size: 0.8rem; color: var(--base); } +.prod-note { margin: 0.25rem 0; font-size: 0.82rem; color: var(--base); } +.prod-hazards { display: flex; flex-wrap: wrap; gap: 0.2rem; margin-top: 0.35rem; } +.match-flag { + margin-left: auto; background: var(--primary-lighter); color: var(--primary-dark); + font-size: 0.68rem; font-weight: 700; padding: 0.08rem 0.4rem; border-radius: 4px; +} + +.inputs-wrap { overflow-x: auto; } +.inputs { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +.inputs th { + text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; + color: var(--base-mid); border-bottom: 1px solid var(--base-lighter); padding: 0.35rem 0.5rem; +} +.inputs td { border-bottom: 1px solid var(--base-lightest); padding: 0.45rem 0.5rem; vertical-align: top; } +.inputs code { font-size: 0.8rem; } +.in-label { font-size: 0.82rem; color: var(--base-dark); } +.in-doc { font-size: 0.78rem; color: var(--base); margin-top: 0.15rem; max-width: 34rem; } +.req { color: var(--error); } + +/* ============ Event catalog ============ */ +.events-dates { max-width: 420px; margin-top: 0.7rem; } +.elist { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0.8rem; } + +.ecard { + width: 100%; text-align: left; font: inherit; color: inherit; cursor: pointer; + background: var(--white); border: 1px solid var(--base-lighter); border-left: 4px solid var(--primary); + border-radius: var(--radius); box-shadow: var(--shadow); padding: 0.8rem 0.9rem; + display: flex; flex-direction: column; gap: 0.4rem; height: 100%; + transition: box-shadow 0.15s, transform 0.15s; +} +.ecard:hover { box-shadow: var(--shadow-lg); transform: translateY(-1px); } +.ecard-top { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; } +.ecard-name { margin: 0; font-size: 1rem; line-height: 1.25; flex: 1 1 auto; } +.ecard-meta { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; font-size: 0.82rem; color: var(--base); } +.ecard-meta code { font-size: 0.78rem; background: var(--base-lightest); padding: 0.05rem 0.35rem; border-radius: 4px; } +.ecard-when { font-variant-numeric: tabular-nums; } +.ecard-hazards { display: flex; flex-wrap: wrap; gap: 0.25rem; } +.ecard-locations { margin: 0; font-size: 0.85rem; color: var(--base-dark); } +.ecard-layers { margin: auto 0 0; font-size: 0.78rem; color: var(--base-mid); font-variant-numeric: tabular-nums; } + +/* `published` = in data/events.json. `submitted` = filed through this app, not yet merged. The + distinction is load-bearing, so it is a color AND a word, never color alone. */ +.src-badge { + font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; + padding: 0.1rem 0.42rem; border-radius: 4px; white-space: nowrap; +} +.src-badge.published { background: var(--primary-lighter); color: var(--primary-dark); } +.src-badge.submitted { background: var(--warning); color: #3d2600; } +.upd-badge { font-size: 0.7rem; color: var(--base); background: var(--base-lightest); padding: 0.1rem 0.4rem; border-radius: 4px; } + +.stac { background: var(--base-lightest); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.82rem; } +.coverage { + margin: 0.9rem 0 0; font-size: 0.88rem; color: var(--base-dark); line-height: 1.5; + background: var(--primary-lighter); border-radius: 6px; padding: 0.6rem 0.75rem; +} + +.lchips { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.lchip { + font: inherit; font-size: 0.8rem; border: 1px solid var(--base-light); background: var(--white); + color: var(--ink); border-radius: 20px; padding: 0.22rem 0.6rem; cursor: pointer; + display: inline-flex; align-items: center; gap: 0.3rem; +} +.lchip:hover { box-shadow: var(--shadow); } +/* Layers no algorithm here produces. Greyed, no pointer, no hover — inert on purpose. */ +.lchip.ext { + background: var(--base-lightest); border-color: var(--base-lighter); color: var(--base-mid); + cursor: default; box-shadow: none; +} + +.sub-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; } +.sub-row { border: 1px solid var(--base-lighter); border-radius: 6px; padding: 0.5rem 0.65rem; } +.sub-head { display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; font-size: 0.88rem; } +.sub-notes { margin: 0.3rem 0 0; font-size: 0.83rem; color: var(--base); } + +/* ============ Responsive ============ */ +@media (max-width: 760px) { + .hub-header { height: auto; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem 0.8rem; } + .hub-tabs { order: 3; width: 100%; flex-basis: 100%; overflow-x: auto; } + .hub-tabs button { padding: 0.5rem 0.6rem; font-size: 0.88rem; } + .hub-right { margin-left: auto; } + .hub-main { padding: 0.85rem; } + + .field.two { grid-template-columns: 1fr; } + .reset-btn { margin-left: 0; } + .toolbar { gap: 0.45rem; } + .search { flex-basis: 100%; } + + .context { grid-template-columns: 1fr; padding: 0.75rem; } + .agrid, .elist { grid-template-columns: 1fr; } + .detail-grid { grid-template-columns: 1fr; gap: 0.1rem 0; } + .detail-grid dt { margin-top: 0.5rem; } + .overlay { padding: 1rem 0.6rem; } + .modal { padding: 0.9rem; max-height: 92vh; } + .mode-row .seg { width: 100%; } + .mode-row .seg button { flex: 1; padding: 0.45rem 0.4rem; font-size: 0.85rem; } +} diff --git a/algorithm-catalog/src/tabs.ts b/algorithm-catalog/src/tabs.ts new file mode 100644 index 0000000..f835b36 --- /dev/null +++ b/algorithm-catalog/src/tabs.ts @@ -0,0 +1,67 @@ +// The header tab configuration. Adding a tab = one entry here plus its component — the shell in +// App.tsx renders whatever is listed. dse-hub's 33-line pattern, flattened: this app has no +// subtabs, so `HeaderTab` carries the component directly. +// +// Order is deliberate and matches how the app is used: SUBMIT first (it is the landing tab — a +// responder arrives with an event to file), then the two viewing tabs. + +import { createElement, type ComponentType } from "react"; +import Catalog from "./catalog/Catalog"; +import EventList from "./events/EventList"; +import RequestForm from "./request/RequestForm"; +import { loadAlgorithms, loadHazards } from "./data"; + +export interface HeaderTab { + id: string; + label: string; + icon?: string; + component: ComponentType; +} + +// RequestForm is deliberately data-agnostic (it takes the catalog as props), so its tab entry is a +// zero-prop adapter. The datasets are plain bundled JSON, so loading them once at module scope is +// free and keeps the adapter from re-deriving them on every render. +const ALGORITHMS = loadAlgorithms(); +const HAZARDS = loadHazards(); +const SubmitTab = () => createElement(RequestForm, { algorithms: ALGORITHMS, hazards: HAZARDS }); +SubmitTab.displayName = "SubmitTab"; + +export const TABS: HeaderTab[] = [ + { id: "submit", label: "Submit", icon: "📝", component: SubmitTab }, + { id: "events", label: "Events", icon: "🌀", component: EventList }, + { id: "algorithms", label: "Algorithms", icon: "🛰", component: Catalog }, +]; + +// --------------------------------------------------------------------------------------------- +// Cross-tab navigation, without a router (zero new dependencies) +// +// Clicking a layer chip on an event has to do two things at once: switch the header tab, and hand +// the Algorithms tab something to filter to. A plain callback cannot do it — the Algorithms tab is +// UNMOUNTED while Events is showing, so it is not there to receive anything. So the intent is +// PARKED in a module variable and announced on `window`: App (always mounted) hears the event and +// switches tabs; the newly-mounted tab picks the parked intent up in its mount effect. +// --------------------------------------------------------------------------------------------- + +export interface NavIntent { + tab: string; + /** Algorithm id to narrow the sensor facet to. */ + algorithm?: string; + /** ActivationEvent id to prefill the facets from. */ + event?: string; +} + +export const NAV_EVENT = "algorithm-catalog:navigate"; + +let parked: NavIntent | null = null; + +export function navigate(intent: NavIntent): void { + parked = intent; + window.dispatchEvent(new CustomEvent(NAV_EVENT, { detail: intent })); +} + +/** Read-and-clear. Safe to call twice — React StrictMode double-invokes mount effects in dev. */ +export function takeNavIntent(): NavIntent | null { + const intent = parked; + parked = null; + return intent; +} diff --git a/algorithm-catalog/src/types.ts b/algorithm-catalog/src/types.ts new file mode 100644 index 0000000..381e366 --- /dev/null +++ b/algorithm-catalog/src/types.ts @@ -0,0 +1,159 @@ +// The data contract for the Algorithm Catalog. +// +// Two canonical datasets, both committed JSON bundled at build time: +// data/algorithms.json — the reusable catalog (what we can run) +// data/events.json — past activation events (what we did run, and when/where) +// plus data/hazards.json, the controlled hazard vocabulary this app DEFINES (the upstream +// disasters-product-algorithms repo has none — hazard is a free-text token in slot 2 of the +// activation_event string, which is exactly why Fire/Wildfire and Quake/Earthquake both exist +// in the wild today). +// +// Submissions land as data/requests/-.json and are overlaid at load time. + +/** Sensing modality — drives the modality filter and the card accent color. */ +export type Modality = "optical" | "sar" | "nightlights" | "utility"; + +/** + * Whether the algorithm is actually registered and runnable. + * - production: a real dps//algorithm_config.yaml exists on dev + * - prototype: code exists in src/ but there is no DPS algorithm (e.g. iceye) + */ +export type AlgorithmStatus = "production" | "prototype"; + +/** + * How the algorithm is pointed at a place. These are mutually incompatible across algorithms — + * there is no unified AOI input upstream, which is why AOI cannot narrow the catalog. + */ +export type SpatialSelector = + | "bbox" // WGS84 "min_lon,min_lat,max_lon,max_lat" (blackmarble) + | "mgrs-tile" // Sentinel-2 MGRS tile IDs + | "wrs2-pathrow" // Landsat WRS-2 path/row, NNNNNN + | "vendor-scene" // implicit: the AOI is whatever the vendor scene covers; select by date + | "granule-file" // operator supplies a .tar/.zip granule + | "none"; + +/** A DPS job input, mirrored from the algorithm's algorithm_config.yaml `inputs` list. */ +export interface AlgorithmInput { + name: string; + label: string; + type: string; // string | int | float | boolean | File | Directory + required: boolean; // true when the upstream YAML declares no default + doc?: string; + default?: string; +} + +/** + * One product (a "layer") an algorithm can generate. `id` is the token the upstream CLI accepts + * (e.g. `swir`), `folder` is the output subdirectory it writes to (e.g. `shortwaveInfrared`). + * `hazards` is the hand-built suitability mapping — no upstream source defines it. + */ +export interface Product { + id: string; + label: string; + folder: string; + /** + * Every hazard this product COULD be useful for. Broad on purpose — this drives discovery + * (the Algorithms tab hazard filter), where a false negative is worse than a false positive. + */ + hazards: string[]; // hazard ids from data/hazards.json + /** + * The hazards for which this is a product you'd ORDER FIRST — always a subset of `hazards`. + * This is what the Submit form auto-selects from. Kept deliberately sparse: selecting "Fire" + * should propose ~7 products, not all 21 that merely mention fire. The user can always add + * more by hand, so under-selecting is the safe default. + */ + primaryHazards: string[]; + note?: string; +} + +export interface Algorithm { + id: string; // catalog key, e.g. "sentinel2" — matches the dps/ name + algorithmName: string; // registered DPS name, e.g. "disasters-sentinel2-process" + version: string; // the git ref DPS clones, e.g. "dev" + title: string; // display name, e.g. "Sentinel-2" + modality: Modality; + status: AlgorithmStatus; + vendor: string; + resolution: string; + /** First date data exists. Two are enforced upstream; the rest are mission-launch dates. */ + temporalStart: string; // "YYYY-MM-DD" + temporalStartApprox: boolean; + spatialSelector: SpatialSelector; + resources: { ramMin: number; coresMin: number; outdirMax: number }; + thumb: string; // "/thumbs/.png" + thumbCredit: string; + configUrl: string; + description: string; + keywords: string[]; + products: Product[]; + inputs: AlgorithmInput[]; +} + +/** A layer an event required that maps onto a real catalog algorithm + product. */ +export interface EventLayer { + algorithm: string; // Algorithm["id"] + product: string; // Product["id"] +} + +export interface ActivationEvent { + id: string; // == stacName + name: string; // human-readable, visible on the website + stacName: string; // YYYYMM_Hazard_Location + start: string; // "YYYY-MM-DD" + end: string; // "YYYY-MM-DD" + hazards: string[]; // hazard ids + locations: string[]; + bbox?: string; + layers: EventLayer[]; + /** Layers the event required that have NO algorithm in this catalog — shown greyed out. */ + externalLayers: string[]; +} + +/** + * The controlled hazard vocabulary. `token` is what goes in slot 2 of a STAC event name and + * MUST NOT contain an underscore (upstream _validate.sh matches slot 2 with `[^_]+`), so + * multi-word hazards are CamelCase in the token and spaced in the label. + */ +export interface Hazard { + id: string; + label: string; + token: string; + color: string; + icon: string; + /** Other tokens seen in the wild that mean this hazard (Wildfire -> Fire, Quake -> Earthquake). */ + aliases: string[]; +} + +/** Where a product on a request came from: hazard auto-selection, or the user adding it by hand. */ +export type ProductOrigin = "hazard" | "manual"; + +export interface RequestProduct { + algorithm: string; + product: string; + via: ProductOrigin; +} + +/** One submission — written to data/requests/-.json and opened as a prefilled PR. */ +export interface AlgorithmRequest { + kind: "algorithm-request"; + id: string; + ts: string; // ISO + event: { + name: string; + stacName: string; + start: string; + end: string; + hazards: string[]; + locations: string[]; + bbox?: string; + /** + * Optional scene selectors, for when the requester already knows exactly which tiles they + * need instead of describing a place. These map straight onto the real algorithm inputs: + * `mgrs` -> sentinel2's `tile`, `wrs2` -> landsat's `process_tile`. + */ + tiles?: { mgrs?: string[]; wrs2?: string[] }; + }; + products: RequestProduct[]; + requester?: string; + notes?: string; +} diff --git a/algorithm-catalog/src/useClickAway.ts b/algorithm-catalog/src/useClickAway.ts new file mode 100644 index 0000000..abfec27 --- /dev/null +++ b/algorithm-catalog/src/useClickAway.ts @@ -0,0 +1,26 @@ +import { RefObject, useEffect } from "react"; + +// Close a popover when the user clicks/taps outside `ref` (instead of on mouse-leave, which +// makes small dropdowns disappear the moment the cursor drifts off). +// Copied verbatim from leave-dashboard/src/useClickAway.ts — it handles touchstart and Escape, +// which dse-hub's older copy does not. +export function useClickAway(ref: RefObject, onAway: () => void, active: boolean) { + useEffect(() => { + if (!active) return; + const handler = (e: Event) => { + const target = e.target as Node; + if (ref.current && !ref.current.contains(target)) onAway(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onAway(); + }; + document.addEventListener("mousedown", handler); + document.addEventListener("touchstart", handler); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("touchstart", handler); + document.removeEventListener("keydown", onKey); + }; + }, [ref, onAway, active]); +} diff --git a/algorithm-catalog/src/vite-env.d.ts b/algorithm-catalog/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/algorithm-catalog/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/algorithm-catalog/tsconfig.json b/algorithm-catalog/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/algorithm-catalog/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/algorithm-catalog/tsconfig.node.json b/algorithm-catalog/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/algorithm-catalog/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/algorithm-catalog/vite.config.ts b/algorithm-catalog/vite.config.ts new file mode 100644 index 0000000..8cbfa42 --- /dev/null +++ b/algorithm-catalog/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Static SPA. All data is committed JSON under data/ and bundled at build time via a plain import +// plus import.meta.glob for the request overlay (see src/data.ts). No backend, no env vars, no +// runtime token — GitHub is the only datastore and approved PRs are what update it (Netlify +// rebuilds on merge). +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + }, +}); diff --git a/docs/ALGORITHM_CATALOG.md b/docs/ALGORITHM_CATALOG.md new file mode 100644 index 0000000..a14e322 --- /dev/null +++ b/docs/ALGORITHM_CATALOG.md @@ -0,0 +1,96 @@ +# Algorithm Catalog (`algorithm-catalog/`) + +A Vite + React + TS app (USWDS palette, **light-only**) cataloguing the NASA Disasters product +algorithms and taking new activation requests. Its own Netlify site (Pattern B, base dir = +`algorithm-catalog`). Like dse-hub it has **no generator/Action** — data is committed JSON edited +entirely through in-app **prefilled PRs** (no backend, no token, no runtime fetch). + +## Shell +Three flat header tabs in `src/tabs.ts`, rendered by `src/App.tsx` and synced to the URL hash: +**📝 Submit** (the landing tab), **🌀 Events**, **🛰 Algorithms**. A layer chip on an event jumps to +the Algorithms tab through a parked `NavIntent` on a `window` event — the target tab is unmounted +when the click happens, so a callback cannot reach it. The header also holds the 🧺 **Requests** +cart. + +## Two entry modes +- **Browse** — Events and Algorithms answer "what have we run, and what could we run?" Filters are + hazard / date / sensor / product; AOI deliberately does **not** narrow the catalog, because the + spatial selectors are mutually incompatible across algorithms (`SpatialSelector` in `types.ts`). +- **Submit** — the form in `src/request/RequestForm.tsx` takes an activation event and turns it into + a reviewable request. **The user never sees DPS**: no queue, no `run_command`, no `ram_min`, no + credentials. Picking hazards auto-selects the suited products; an explicit **+ Add products** + picker covers everything the hand-built mapping misses (chips are badged `auto` vs `manual`, the + `via` field). A collapsed **"What this will run"** panel shows the derived DPS inputs read-only, so + the request is traceable without ever being editable. + +## Data model (GitHub-only, merged at BUILD) +- Canonical: **`data/algorithms.json`** (the catalog), **`data/events.json`** (past activations), + **`data/hazards.json`** (the controlled vocabulary this app *defines* — upstream has none, which + is why `Fire`/`Wildfire` and `Quake`/`Earthquake` both exist in the wild), and + **`data/requests.json`** (compacted submissions). All bundled at build via plain imports. +- A submission is an **`AlgorithmRequest`** staged in the cart (`src/RequestsContext.tsx`, + `localStorage algorithm-catalog:requests`, upsert by `id`). +- **Hazards and products serialize by `id`, never by label** — `Fire`, `TropicalCyclone`, `WinterWx`, + `swir`, `mndwi`. A payload carrying a display label is a hard validation error. +- `src/data.ts` reads the canonical arrays **and** `import.meta.glob`s `data/requests/*.json`. Both + are needed: globbing only the directory would make every compacted request vanish the moment the + compact workflow runs. + +## The standards story (three layers, one source of truth) +Every rule is lifted from the real enforcement point in +`Disasters-Learning-Portal/disasters-product-algorithms@dev`, so the app rejects exactly what DPS +rejects at run time — while you type, instead of 40 minutes into a job. +1. **`src/rules.ts`** — THE STANDARD (`STAC_EVENT_RE`, `ISO_DATE_RE`, `checkBbox`, …). The form calls + `validateRequest()` and renders each `Violation` **inline under its own field**, red for `error` + and amber for `warning`, naming the rule and giving a worked example. `isSubmittable()` gates the + submit buttons; **warnings never block**. Violations stay hidden until a field is touched (Submit + is the landing tab — an untouched form must not open on a wall of red); **"Show all problems"** + reveals everything, which is the only way a *disabled* button can explain itself. +2. **`scripts/validate_data.py`** — the same rules in stdlib Python, over every committed file, plus + reference integrity (hazard/product ids resolve, every `thumb` exists on disk). +3. **`.github/workflows/algorithm-catalog-validate.yml`** — runs both scripts on every PR touching + `algorithm-catalog/**`, plus `scripts/rules_parity_test.py`, which pushes one fixture table + through **both** rule implementations so the two copies can never silently drift. A second job + runs `npm ci && npm run typecheck && npm run build` on Node 24. + +## Prefilled-PR flow +`src/requests.ts` builds ONE file `algorithm-catalog/data/requests/-.json` (stamp = +newest `ts`, non-alphanumerics → `-`) and opens +`github.com/NASA-IMPACT/veda-github-actions/new/main?filename=&value=`. The user clicks *commit*; +that is the whole flow — no OAuth, no token. **`URL_LIMIT` is 190000**, not dse-hub's 7500: one +request over a broad hazard set easily carries 40+ products. Past the limit the PR button disables +and the cart tells you to use **Copy JSON**. Unique filenames make concurrent submissions +conflict-free. `scripts/compact.mjs` + `.github/workflows/algorithm-catalog-compact.yml` fold merged +request files into `data/requests.json` and delete them, with `[skip ci]` so the fold does not +re-trigger validation. + +## Theming +**Light-only USWDS**, like dse-hub — there is no dark toggle and no `data-theme` attribute. Colors +come from custom properties in `src/styles.css`; `src/request.css` holds the request form's own +`.req-`-prefixed classes and reuses the shared house classes (`.btn .panel .field .hint .overlay +.modal .foot .jsonlabel .urlbox .opt .rm .seg .control .popover`) rather than redefining them. +Hazard colors and the `auto`/`manual` product badges carry meaning and are fixed, not themed. + +## Gotchas +- **Root `.gitignore` has `*.json`.** Unlike dse-hub (which needs `git add -f`), this app's paths are + explicitly re-included — `!algorithm-catalog/data/*.json`, `!algorithm-catalog/data/requests/*.json`, + `package.json`, `package-lock.json`, `tsconfig*.json` — so a plain `git add algorithm-catalog/` + picks the data up. Add a new JSON path under `algorithm-catalog/` and you must add its negation too. +- **Thumbnails are committed binaries** in `public/thumbs/` (600×400 PNG, ≤150 KB, sources and NASA + credit lines in `public/thumbs/CREDITS.md`). `validate_data.py` fails if an algorithm's `thumb` has + no file on disk, so a new algorithm needs its PNG in the same PR. +- Netlify: set **base directory = `algorithm-catalog`**; the first deploy fails if run before + `algorithm-catalog/` exists on the built branch ("base directory not found") — **deploy after the + merge lands on `main`**. + +## Run / test +```bash +cd algorithm-catalog +npm install +npm run dev # local dev +npm run typecheck # tsc --noEmit +npm run build # vite build -> dist/ +node scripts/compact.mjs # fold data/requests/*.json into data/requests.json +python3 scripts/validate_data.py # the standards gate, offline, stdlib only +python3 scripts/rules_parity_test.py # rules.ts vs validate_data.py must agree +```