From 4fd6c28e1daaae293186414a72269615b8602264 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Tue, 19 May 2026 18:02:38 +0545 Subject: [PATCH 1/3] feat: setup gql and submodule for malawi risk watch --- .github/workflows/ci.yml | 1 + .gitmodules | 3 + app/codegen.ts | 17 + app/env.ts | 1 + app/eslint.config.ts | 1 + app/graphql.stub.ts | 10 + app/package.json | 10 +- app/src/App/index.tsx | 12 +- app/src/config.ts | 2 + app/src/utils/graphql/index.ts | 15 + knip.jsonc | 5 +- malawi-risk-watch-backend | 1 + nginx-serve/apply-config.sh | 2 + nginx-serve/helm/templates/configmap.yaml | 1 + nginx-serve/helm/values-test.yaml | 1 + nginx-serve/helm/values.yaml | 1 + pnpm-lock.yaml | 1585 ++++++++++++++++++++- 17 files changed, 1625 insertions(+), 43 deletions(-) create mode 100644 app/codegen.ts create mode 100644 app/graphql.stub.ts create mode 100644 app/src/utils/graphql/index.ts create mode 160000 malawi-risk-watch-backend diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e87ac57ed..81ef2facb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ env: APP_ENVIRONMENT: 'development' APP_MAPBOX_ACCESS_TOKEN: 'dummy-token' APP_RISK_API_ENDPOINT: 'https://go-risk-api-stage.ifrc.org/' + APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT: 'https://malawi-risk-watch-stage.example.com/graphql/' APP_TINY_API_KEY: 'dummy-api-key' APP_TITLE: 'IFRC Go Test' APP_TRANSLATION_API_ENDPOINT: 'https://cacheppuccino-stage.ifrc.org/' diff --git a/.gitmodules b/.gitmodules index 89bab0470b..8efa81be94 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "cacheppuccino"] path = cacheppuccino url = git@github.com:IFRCGo/cacheppuccino.git +[submodule "malawi-risk-watch-backend"] + path = malawi-risk-watch-backend + url = git@github.com:toggle-corp/malawi-risk-watch-backend.git diff --git a/app/codegen.ts b/app/codegen.ts new file mode 100644 index 0000000000..37b98de2dd --- /dev/null +++ b/app/codegen.ts @@ -0,0 +1,17 @@ +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + schema: '../malawi-risk-watch-backend/schema.graphql', + documents: ['src/**/*.{ts,tsx}'], + ignoreNoDocuments: true, + generates: { + './generated/gql/': { + preset: 'client', + presetConfig: { + fragmentMasking: false, + }, + }, + }, +}; + +export default config; diff --git a/app/env.ts b/app/env.ts index e1897ad90a..59759e6f7b 100644 --- a/app/env.ts +++ b/app/env.ts @@ -23,6 +23,7 @@ export default defineConfig({ APP_MAPBOX_ACCESS_TOKEN: Schema.string(), APP_TINY_API_KEY: Schema.string(), APP_RISK_API_ENDPOINT: Schema.string({ format: 'url', protocol: true }), + APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT: Schema.string({ format: 'url', protocol: true, tld: false }), APP_SDT_URL: Schema.string.optional({ format: 'url', protocol: true, tld: false }), APP_POWER_BI_REPORT_ID_1: Schema.string.optional(), APP_SENTRY_DSN: Schema.string.optional(), diff --git a/app/eslint.config.ts b/app/eslint.config.ts index 821f875bb7..6a8e95d747 100644 --- a/app/eslint.config.ts +++ b/app/eslint.config.ts @@ -80,6 +80,7 @@ const appConfigs = compat.config({ 'postcss.config.cjs', 'stylelint.config.cjs', 'vite.config.ts', + 'codegen.ts', ], optionalDependencies: false, }, diff --git a/app/graphql.stub.ts b/app/graphql.stub.ts new file mode 100644 index 0000000000..2c5babbd85 --- /dev/null +++ b/app/graphql.stub.ts @@ -0,0 +1,10 @@ +/* +Stub for graphql-codegen `client-preset` output. +Both lint and typecheck steps fail if `generated/gql/index.ts` is missing. +We generally generate this file by running `pnpm generate:type:malawi-graphql`. +We cannot always generate this file (e.g. without the submodule), so we just +copy this stub to ensure lint and typecheck do not fail. +*/ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const graphql: (source: string) => any = () => ({}); diff --git a/app/package.json b/app/package.json index 56671d29cf..d46a42d2a9 100644 --- a/app/package.json +++ b/app/package.json @@ -14,14 +14,16 @@ "translatte:generate": "pnpm translatte generate-migration ../translationMigrations ./src/**/i18n.json ../packages/ui/src/**/i18n.json", "translatte:lint": "pnpm translatte lint ./src/**/i18n.json ../packages/ui/src/**/i18n.json", "translatte:lint-migrations": "pnpm translatte lint-migrations ../translationMigrations", - "initialize:type": "mkdir -p generated/ && pnpm initialize:type:go-api && pnpm initialize:type:risk-api && pnpm initialize:type:translations", + "initialize:type": "mkdir -p generated/ && pnpm initialize:type:go-api && pnpm initialize:type:risk-api && pnpm initialize:type:translations && pnpm initialize:type:malawi-graphql", "initialize:type:go-api": "test -f ./generated/types.ts && true || cp types.stub.ts ./generated/types.ts", "initialize:type:risk-api": "test -f ./generated/riskTypes.ts && true || cp types.stub.ts ./generated/riskTypes.ts", "initialize:type:translations": "test -f ./generated/translationTypes.ts && true || cp types.stub.ts ./generated/translationTypes.ts", - "generate:type": "pnpm generate:type:go-api && pnpm generate:type:risk-api && pnpm generate:type:translations", + "initialize:type:malawi-graphql": "mkdir -p generated/gql && (test -f ./generated/gql/index.ts || cp graphql.stub.ts ./generated/gql/index.ts)", + "generate:type": "pnpm generate:type:go-api && pnpm generate:type:risk-api && pnpm generate:type:translations && pnpm generate:type:malawi-graphql", "generate:type:go-api": "openapi-typescript ../go-api/assets/openapi-schema.yaml -o ./generated/types.ts --alphabetize", "generate:type:risk-api": "openapi-typescript ../go-risk-module-api/openapi-schema.yaml -o ./generated/riskTypes.ts --alphabetize", "generate:type:translations": "openapi-typescript ../cacheppuccino/openapi.json -o ./generated/translationTypes.ts --alphabetize", + "generate:type:malawi-graphql": "graphql-codegen --config codegen.ts", "prestart": "pnpm initialize:type", "start": "pnpm -F @ifrc-go/ui build && vite", "prebuild": "pnpm initialize:type", @@ -55,6 +57,7 @@ "diff-match-patch": "^1.0.5", "exceljs": "^4.4.0", "file-saver": "^2.0.5", + "graphql": "^16.14.0", "html-to-image": "^1.11.13", "mapbox-gl": "^1.13.3", "papaparse": "^5.5.3", @@ -63,12 +66,15 @@ "react-dom": "^19.0.0", "react-router-dom": "^7.0.0", "sanitize-html": "^2.17.2", + "urql": "^5.0.2", "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@eslint/json": "^1.0.0", + "@graphql-codegen/cli": "^7.0.0", + "@graphql-codegen/client-preset": "^6.0.0", "@julr/vite-plugin-validate-env": "^2.0.0", "@types/file-saver": "^2.0.7", "@types/mapbox-gl": "^1.13.10", diff --git a/app/src/App/index.tsx b/app/src/App/index.tsx index 95a650a3d3..ed171feff6 100644 --- a/app/src/App/index.tsx +++ b/app/src/App/index.tsx @@ -34,6 +34,10 @@ import { KEY_LANGUAGE_STORAGE, KEY_USER_STORAGE, } from '#utils/constants'; +import { + malawiRiskWatchGraphqlClient, + UrqlProvider, +} from '#utils/graphql'; import { getFromStorage, removeFromStorage, @@ -240,9 +244,11 @@ function Application() { - + + + diff --git a/app/src/config.ts b/app/src/config.ts index 8475164dca..f20620ab36 100644 --- a/app/src/config.ts +++ b/app/src/config.ts @@ -6,6 +6,7 @@ const { APP_MAPBOX_ACCESS_TOKEN, APP_TINY_API_KEY, APP_RISK_API_ENDPOINT, + APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT, APP_TRANSLATION_API_ENDPOINT, APP_SDT_URL, APP_POWER_BI_REPORT_ID_1, @@ -31,6 +32,7 @@ export const api = APP_API_ENDPOINT; export const adminUrl = APP_ADMIN_URL ?? `${api}admin/`; export const mbtoken = APP_MAPBOX_ACCESS_TOKEN; export const riskApi = APP_RISK_API_ENDPOINT; +export const malawiRiskWatchGraphqlApi = APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT; export const translationApi = APP_TRANSLATION_API_ENDPOINT; export const sdtUrl = APP_SDT_URL; export const powerBiReportId1 = APP_POWER_BI_REPORT_ID_1; diff --git a/app/src/utils/graphql/index.ts b/app/src/utils/graphql/index.ts new file mode 100644 index 0000000000..d6d4c32d90 --- /dev/null +++ b/app/src/utils/graphql/index.ts @@ -0,0 +1,15 @@ +import { + cacheExchange, + createClient, + fetchExchange, + Provider as UrqlProvider, +} from 'urql'; + +import { malawiRiskWatchGraphqlApi } from '#config'; + +export const malawiRiskWatchGraphqlClient = createClient({ + url: malawiRiskWatchGraphqlApi, + exchanges: [cacheExchange, fetchExchange], +}); + +export { UrqlProvider }; diff --git a/knip.jsonc b/knip.jsonc index 8b249e1a01..2b284db8f6 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -32,7 +32,10 @@ // "openapi-typescript", // We are using this in eslint.config.ts but we are getting a false positive "@typescript-eslint/parser", - "@typescript-eslint/eslint-plugin" + "@typescript-eslint/eslint-plugin", + // Only invoked via the `generate:type:malawi-graphql` script + "@graphql-codegen/cli", + "@graphql-codegen/client-preset" ], "entry": [ "eslint.config.ts", diff --git a/malawi-risk-watch-backend b/malawi-risk-watch-backend new file mode 160000 index 0000000000..3d3e5ba956 --- /dev/null +++ b/malawi-risk-watch-backend @@ -0,0 +1 @@ +Subproject commit 3d3e5ba956d11a88a50d5f4aa593aad26ed17d3e diff --git a/nginx-serve/apply-config.sh b/nginx-serve/apply-config.sh index 1e020def6e..dcb8a3850f 100755 --- a/nginx-serve/apply-config.sh +++ b/nginx-serve/apply-config.sh @@ -33,6 +33,8 @@ find "$DESTINATION_DIRECTORY" -type f -exec sed -i "s|\|$APP_SENTRY_DSN|g" {} + diff --git a/nginx-serve/helm/templates/configmap.yaml b/nginx-serve/helm/templates/configmap.yaml index 2aa6a35da9..8f8f25d315 100644 --- a/nginx-serve/helm/templates/configmap.yaml +++ b/nginx-serve/helm/templates/configmap.yaml @@ -14,5 +14,6 @@ data: APP_MAPBOX_ACCESS_TOKEN: {{ required "env.APP_MAPBOX_ACCESS_TOKEN" .Values.env.APP_MAPBOX_ACCESS_TOKEN | quote }} APP_TINY_API_KEY: {{ required "env.APP_TINY_API_KEY" .Values.env.APP_TINY_API_KEY | quote }} APP_RISK_API_ENDPOINT: {{ required "env.APP_RISK_API_ENDPOINT" .Values.env.APP_RISK_API_ENDPOINT | quote }} + APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT: {{ required "env.APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT" .Values.env.APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT | quote }} APP_SDT_URL: {{ required "env.APP_SDT_URL" .Values.env.APP_SDT_URL | quote }} APP_SENTRY_DSN: {{ required "env.APP_SENTRY_DSN" .Values.env.APP_SENTRY_DSN | quote }} diff --git a/nginx-serve/helm/values-test.yaml b/nginx-serve/helm/values-test.yaml index 4d4517c572..7b12494a62 100644 --- a/nginx-serve/helm/values-test.yaml +++ b/nginx-serve/helm/values-test.yaml @@ -14,5 +14,6 @@ env: APP_MAPBOX_ACCESS_TOKEN: RANDOM_DUMMY_TOKEN APP_TINY_API_KEY: RANDOM_DUMMY_TOKEN APP_RISK_API_ENDPOINT: https://risk-1-api.test.com + APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT: https://malawi-1-api.test.com/graphql/ APP_SENTRY_DSN: https://random-token@random-user@sentry-test.io/10000 APP_SDT_URL: https://alpha-1-sdt.test.com diff --git a/nginx-serve/helm/values.yaml b/nginx-serve/helm/values.yaml index 0c1da59841..6a7034ff47 100644 --- a/nginx-serve/helm/values.yaml +++ b/nginx-serve/helm/values.yaml @@ -23,5 +23,6 @@ env: APP_MAPBOX_ACCESS_TOKEN: APP_TINY_API_KEY: APP_RISK_API_ENDPOINT: + APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT: APP_SENTRY_DSN: APP_SDT_URL: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eddf18a9c..8e0ccc5e76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: file-saver: specifier: ^2.0.5 version: 2.0.5 + graphql: + specifier: ^16.14.0 + version: 16.14.0 html-to-image: specifier: ^1.11.13 version: 1.11.13 @@ -97,6 +100,9 @@ importers: sanitize-html: specifier: ^2.17.2 version: 2.17.2 + urql: + specifier: ^5.0.2 + version: 5.0.2(@urql/core@6.0.1(graphql@16.14.0))(react@19.2.4) xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -110,6 +116,12 @@ importers: '@eslint/json': specifier: ^1.0.0 version: 1.2.0 + '@graphql-codegen/cli': + specifier: ^7.0.0 + version: 7.0.0(@types/node@20.19.37)(graphql@16.14.0)(typescript@5.9.3) + '@graphql-codegen/client-preset': + specifier: ^6.0.0 + version: 6.0.0(graphql@16.14.0) '@julr/vite-plugin-validate-env': specifier: ^2.0.0 version: 2.2.2(vite@6.4.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -553,9 +565,22 @@ importers: packages: + '@0no-co/graphql.web@1.2.0': + resolution: {integrity: sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ardatan/relay-compiler@13.0.1': + resolution: {integrity: sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg==} + peerDependencies: + graphql: '*' + '@ast-grep/napi-darwin-arm64@0.36.3': resolution: {integrity: sha512-uM0Hrm5gcHqaBL64ktmPBFMTorTlPKWsUfi0E2Cg09GJfeYWvZmicCqgd7qVtjURmQvFQdb4JSqHIkJvws6Uqw==} engines: {node: '>= 10'} @@ -648,6 +673,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -669,6 +698,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-import-assertions@7.28.6': + resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime-corejs3@7.29.2': resolution: {integrity: sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==} engines: {node: '>=6.9.0'} @@ -1094,6 +1129,18 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@envelop/core@5.5.1': + resolution: {integrity: sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==} + engines: {node: '>=18.0.0'} + + '@envelop/instrumentation@1.0.0': + resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} + engines: {node: '>=18.0.0'} + + '@envelop/types@5.2.1': + resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} + engines: {node: '>=18.0.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1604,6 +1651,9 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@figspec/components@1.0.3': resolution: {integrity: sha512-fBwHzJ4ouuOUJEi+yBZIrOy+0/fAjB3AeTcIHTT1PRxLz8P63xwC7R0EsIJXhScIcc+PljGmqbbVJCjLsnaGYA==} @@ -1612,6 +1662,238 @@ packages: peerDependencies: react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@graphql-codegen/add@7.0.0': + resolution: {integrity: sha512-fQGlUQd0BpoevCTOKi3b7M+kuXCI13udXmJrIh1QMtTCLXUTYGgsubNVcPLr0cVjVwyBK/ZRgwtxdCmkVXqTwQ==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/cli@7.0.0': + resolution: {integrity: sha512-SNgTiFU/jB3VJLr8koJjmXAwl60wG/9r5iQBiOmlf0m9KRaiCNmfDG6+VbeejJPkDIGJKQd0SwqV5i+fxdnjqA==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + '@parcel/watcher': ^2.1.0 + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + '@parcel/watcher': + optional: true + + '@graphql-codegen/client-preset@6.0.0': + resolution: {integrity: sha512-nqidNH4rrulv0E2ZVkYcIWz5Jon+hLBKkx/Xp8KyRJ8WnNRD0kJO1ra8ECLU/JS8LuZehSJyCfoQh555TT5TEw==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + + '@graphql-codegen/core@6.0.0': + resolution: {integrity: sha512-/UDolbUC6q6MTHNvEUDq+vC3ugycxAQ71S62WB3RDXzbBVIG5MG5Kw89WFOh+dt/s+mRuX+tx+Vz/si81sZ0aw==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/gql-tag-operations@6.0.0': + resolution: {integrity: sha512-IBwQ/jYx5Z4yMV78oVGU3hhNu/I7xFiQpFXavZujwCvoyH611M/JAoZ/RTExjr9stzcKMmNxJV/1Pknv+5M9Fg==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/plugin-helpers@7.0.1': + resolution: {integrity: sha512-S2X0YT3XQbP2haqhIeku8GOXo2j8QuBu7BrLsOEHz4UeMu78y3rja1Q4ri3oJ0jq4dMgaQlazoVHI/A+FAKMGw==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/schema-ast@6.0.0': + resolution: {integrity: sha512-ww6lfCZYBZk8SbnOKp76FLvBrMD6oqFhAGj8Ov8f+bsrNh0SG1M6mxLWh4nl9hWIu/iwsZgrJcuIrTfyBeF6jQ==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typed-document-node@7.0.0': + resolution: {integrity: sha512-gSsMEKe1QV5QmF+TsijSyhLYGYRYGD2fe7rGIJwca4s1gZK+aD3qjNFq3C0yFUsb92bsCxiOJTWeiPPfdPSMTg==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typescript-operations@6.0.2': + resolution: {integrity: sha512-QYs8eQIOXsGKiWBlx4ZtIwYALa5RlLwznJELcnKayGubuEbglOsapJBswERXoogf3SMDrU21L58c1CYkYlyKIA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + + '@graphql-codegen/typescript@6.0.1': + resolution: {integrity: sha512-UmHKlOBqnmGs0ioZsfi7INIb6+YCXVMi3KSp/INO359fT3RJxWPLPiE7T30UaWXtLeO6fBNZcvhy3EGBI9UCvw==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/visitor-plugin-common@7.0.2': + resolution: {integrity: sha512-3v1dPjkSiAIsqwZ/6btSZ6BCYlYYpr3FdLhsBZ/JoPP2hYgN6AlGKdUyhYm5FsgDKU04L7al3+rfnTOCxqM0gw==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-hive/signal@2.0.0': + resolution: {integrity: sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==} + engines: {node: '>=20.0.0'} + + '@graphql-tools/apollo-engine-loader@8.0.30': + resolution: {integrity: sha512-hUydKGGECrWloERMmfoMzHZi12X99AM9geCGF5XVsv4iMRl/Iyuet24th4kC9bZ8MlAdCwAwtUsCyv9uRfYwSA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/batch-execute@10.0.8': + resolution: {integrity: sha512-Kobt37qrVTFhX4HUK5/vPgMXFw/5f97AzmAlfmDBSRh/GnoAmLKCb48FrEI3gdeIwZB2fEhVHJyDqsojldnLQA==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/code-file-loader@8.1.32': + resolution: {integrity: sha512-gR5mNQjn0BugDL8a4A+ovS2KEvU52RNOGnbwiq9oWAEHiSv7iqJu77bpWARTzlE1ZFPK5MSQe9218+1t5PbXmQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/delegate@12.0.16': + resolution: {integrity: sha512-WEJaFwWG82a0VzhfE4sRsaOPjxgCVfn4fOe3ho+r3uIbPYpc7qHpFdu1PLg6meikq6fuW9NJ1J88fEgnWuXDVg==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/documents@1.0.1': + resolution: {integrity: sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-common@1.0.6': + resolution: {integrity: sha512-23/K5C+LSlHDI0mj2SwCJ33RcELCcyDUgABm1Z8St7u/4Z5+95i925H/NAjUyggRjiaY8vYtNiMOPE49aPX1sg==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-graphql-ws@3.1.5': + resolution: {integrity: sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-http@3.3.0': + resolution: {integrity: sha512-IkKXIjSg9U8MNsQUBVJAXE4+LSxaQ0cs7p5JTALLGDABY1o17vPDRwWALsX81AXD5dY27ihi/+OhGMueW/Fopg==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-legacy-ws@1.1.28': + resolution: {integrity: sha512-O4uj93GG9iUb3s32eyhUohvyfA8mLhN8FvGzEdK628hFQPhZN75yurtVFrR08DHex71mQ3wYCCFkErpwdJbDDQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor@1.5.3': + resolution: {integrity: sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/git-loader@8.0.36': + resolution: {integrity: sha512-PDDakesRu8FJYHJLf9/gkTweh8M19Bymz9i+vOlk9OTs9XmNcCqKM+1S610KX2AodvuBFz/xbesjTtTJIppLPg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/github-loader@9.1.2': + resolution: {integrity: sha512-jhRJncj9Wkr1Cd8Mo3QI2oG6fTw5ILr1/OXcHIqx744NBj8pPwQBXmQzZqh7MXxbekl2EAcum7SJIjq1HpYcPA==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/graphql-file-loader@8.1.14': + resolution: {integrity: sha512-CfAcsSEVkkHfEXLFzrd5rUYpcQEGWNV8lfc1Tb1p5m9HnYICzDDH08I5V33iMrEDza3GuujjjRBYqplBkqwIow==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/graphql-tag-pluck@8.3.31': + resolution: {integrity: sha512-ema2RRPZGj8TKruNElyDBHVCNFMxioGIVfLBuiA+GdfmRGt95b/i7Uksnj4EwItA6MCmhxokxZoa/fl6mJt3tw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/import@7.1.14': + resolution: {integrity: sha512-aqLcu04aEidszbXM6M0PWWL8bP17eX9sxXwjYWpglLvIRd4NFqb3C9QzBY8pleqXNMtWqXktlm9BQjevgSrirQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/json-file-loader@8.0.28': + resolution: {integrity: sha512-qgCsSkPArnjlNkcYpgGKiXxCTNkrAT9E+l1LhR+Por2jTlKBBeZ8stortkQ/PNDDjuL0WPrLQmHKhNPHabnB3A==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/load@8.1.10': + resolution: {integrity: sha512-hjcvfEFtwtc8vGi46wtpmGWadNzfEhzbjqinyFIZuIZPlR4aYdWQtqWtY/RMM4Ew4t1USkMNm6xrqC2TH1vCSA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/merge@9.1.9': + resolution: {integrity: sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/optimize@2.0.0': + resolution: {integrity: sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/relay-operation-optimizer@7.1.4': + resolution: {integrity: sha512-cwOD/GEo/R//1uGCP0/urIxsMFoUgzkJVyMt9BDM2HhQhU6rSgH5l6lFukAFTJyPJVdyeOdYm2i0Jj5vYWbHTw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/schema@10.0.33': + resolution: {integrity: sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/url-loader@9.1.2': + resolution: {integrity: sha512-pVSiPrfWQKb3jq23Pl7EjbB2uv3tgZLnWo/axkmg4itAEZ5s/vV/jKa8P1HZzUnSVUTR+8tcEZVeNsUbzFCbkg==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@11.1.0': + resolution: {integrity: sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/wrap@11.1.15': + resolution: {integrity: sha512-GCMx6l0MPwHVaBMHf29oG8eIrsJ8PBXq9y5DNX9/r9oCpCBfqxfWzcejx4CpO4chA3+yylGOKcAyEbOUgxfI1Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-typed-document-node/core@3.2.0': + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1637,6 +1919,55 @@ packages: peerDependencies: react: '>= 16' + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/checkbox@5.1.5': + resolution: {integrity: sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.0.13': + resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.1.10': + resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.1.2': + resolution: {integrity: sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.0.14': + resolution: {integrity: sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1646,6 +1977,91 @@ packages: '@types/node': optional: true + '@inquirer/external-editor@3.0.0': + resolution: {integrity: sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/input@5.0.13': + resolution: {integrity: sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.0.13': + resolution: {integrity: sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.13': + resolution: {integrity: sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.4.3': + resolution: {integrity: sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.2.9': + resolution: {integrity: sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.1.9': + resolution: {integrity: sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.1.5': + resolution: {integrity: sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2018,6 +2434,9 @@ packages: '@quansync/fs@0.1.6': resolution: {integrity: sha512-zoA8SqQO11qH9H8FCBR7NIbowYARIPmBz3nKjgAaOUDi/xPAAu1uAgebtV7KXHTc6CDZJVRZ1u4wIGvY5CWYaw==} + '@repeaterjs/repeater@3.0.6': + resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} + '@rolldown/binding-android-arm64@1.0.0-rc.12': resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3051,6 +3470,9 @@ packages: cpu: [x64] os: [win32] + '@urql/core@6.0.1': + resolution: {integrity: sha512-FZDiQk6jxbj5hixf2rEPv0jI+IZz0EqqGW8mJBEug68/zHTtT+f34guZDmyjJZyiWbj0vL165LoMr/TkeDHaug==} + '@vitejs/plugin-react-swc@4.3.0': resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3142,6 +3564,22 @@ packages: '@vue/shared@3.5.31': resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==} + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.8.5': + resolution: {integrity: sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -3357,6 +3795,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + autoprefixer@10.4.27: resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} @@ -3761,6 +4203,16 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case-all@2.1.0: + resolution: {integrity: sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw==} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} @@ -3830,6 +4282,10 @@ packages: cli-width@2.2.1: resolution: {integrity: sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -3862,6 +4318,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -3931,6 +4391,10 @@ packages: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} engines: {node: '>= 10'} + cross-inspect@1.0.1: + resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} + engines: {node: '>=16.0.0'} + cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} @@ -4008,6 +4472,10 @@ packages: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@1.1.0: resolution: {integrity: sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==} @@ -4023,12 +4491,19 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dataloader@2.2.3: + resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debounce@3.0.0: + resolution: {integrity: sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==} + engines: {node: '>=20'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -4096,6 +4571,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dependency-graph@1.0.0: + resolution: {integrity: sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==} + engines: {node: '>=4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4108,6 +4587,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4483,6 +4966,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + exceljs@4.4.0: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} @@ -4529,9 +5015,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -4551,6 +5046,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -4628,6 +5127,10 @@ packages: engines: {node: '>=18.3.0'} hasBin: true + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + frac@1.1.2: resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} engines: {node: '>=0.8'} @@ -4783,6 +5286,42 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql-config@5.1.6: + resolution: {integrity: sha512-fCkYnm4Kdq3un0YIM4BCZHVR5xl0UeLP6syxxO7KAstdY7QVyVvTHP0kRPDYEP1v08uwtJVgis5sj3IOTLOniQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + cosmiconfig-toml-loader: ^1.0.0 + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + cosmiconfig-toml-loader: + optional: true + + graphql-tag@2.12.6: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + graphql-ws@6.0.8: + resolution: {integrity: sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==} + engines: {node: '>=20'} + peerDependencies: + '@fastify/websocket': ^10 || ^11 + crossws: ~0.3 + graphql: ^15.10.1 || ^16 + ws: ^8 + peerDependenciesMeta: + '@fastify/websocket': + optional: true + crossws: + optional: true + ws: + optional: true + + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + grid-index@1.1.0: resolution: {integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==} @@ -4911,10 +5450,17 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-from@4.0.0: + resolution: {integrity: sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==} + engines: {node: '>=12.2'} + import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} @@ -4955,6 +5501,10 @@ packages: resolution: {integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==} engines: {node: '>=4'} + is-absolute@1.0.0: + resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} + engines: {node: '>=0.10.0'} + is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -5079,6 +5629,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-relative@1.0.0: + resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} + engines: {node: '>=0.10.0'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -5106,6 +5660,14 @@ packages: is-typedarray@1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unc-path@1.0.0: + resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} + engines: {node: '>=0.10.0'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -5139,6 +5701,16 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -5235,6 +5807,10 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json-to-pretty-yaml@1.2.2: + resolution: {integrity: sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==} + engines: {node: '>= 0.2.0'} + json5@0.5.1: resolution: {integrity: sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==} hasBin: true @@ -5392,6 +5968,10 @@ packages: listenercount@1.0.1: resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + listr2@10.2.1: + resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} + engines: {node: '>=22.13.0'} + lit-element@3.3.3: resolution: {integrity: sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==} @@ -5471,6 +6051,14 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + log-update@7.2.0: resolution: {integrity: sha512-iLs7dGSyjZiUgvrUvuD3FndAxVJk+TywBkkkwUSm9HdYoskJalWg5qVsEiXeufPvRVPbCUmNQewg798rx+sPXg==} engines: {node: '>=20'} @@ -5520,6 +6108,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + map-cache@0.2.2: + resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} + engines: {node: '>=0.10.0'} + map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} @@ -5552,6 +6144,15 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meros@1.3.2: + resolution: {integrity: sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==} + engines: {node: '>=13'} + peerDependencies: + '@types/node': '>=13' + peerDependenciesMeta: + '@types/node': + optional: true + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -5641,6 +6242,10 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5660,13 +6265,26 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + normalize-path@2.1.1: + resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} + engines: {node: '>=0.10.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -5813,6 +6431,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-filepath@1.0.2: + resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} + engines: {node: '>=0.8'} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -5845,6 +6467,14 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-root-regex@0.1.2: + resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} + engines: {node: '>=0.10.0'} + + path-root@0.1.1: + resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} + engines: {node: '>=0.10.0'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -6351,6 +6981,15 @@ packages: resolution: {integrity: sha512-jlQ9gYLfk2p3V5Ag5fYhA7fv7OHzd1KUH0PRP46xc3TgwjwgROIW572AfYg/X9kaNq/LJnu6oJcFRXlIrGoTRw==} hasBin: true + remedial@1.0.8: + resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==} + + remove-trailing-separator@1.1.0: + resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + + remove-trailing-spaces@1.0.9: + resolution: {integrity: sha512-xzG7w5IRijvIkHIjDk65URsJJ7k4J95wmcArY5PRcmjldIOl7oTvG8+X2Ag690R7SfwiOcHrWZKVc1Pp5WIOzA==} + repeating@2.0.1: resolution: {integrity: sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==} engines: {node: '>=0.10.0'} @@ -6420,6 +7059,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -6555,6 +7197,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -6597,6 +7243,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + slice-ansi@8.0.0: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} @@ -6633,6 +7283,9 @@ packages: split@0.3.1: resolution: {integrity: sha512-hCHXkQDs1HFKRsrT9EutGT1hmjS1FW1Aei8dk/CxrT7mslcMtAxbiv8LYA/AYDvjB6h9rSXgW8zAZwg20tKMTw==} + sponge-case@2.0.3: + resolution: {integrity: sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -6676,6 +7329,9 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-env-interpolation@1.0.1: + resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==} + string-width@2.1.1: resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} engines: {node: '>=4'} @@ -6844,9 +7500,16 @@ packages: svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + swap-case@3.0.3: + resolution: {integrity: sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + sync-fetch@0.6.0: + resolution: {integrity: sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ==} + engines: {node: '>=18'} + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -6872,6 +7535,10 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + timeout-signal@2.0.0: + resolution: {integrity: sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==} + engines: {node: '>=16'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -6904,6 +7571,9 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + title-case@3.0.3: + resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -6948,6 +7618,10 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + ts-log@3.0.2: + resolution: {integrity: sha512-esq6hx2lM66sQV1YcFkIYTqrWWabmqBqobKHyn1CswdI5FgfQhkmiKiRWVGBNlIbdjBxEIkNvMIwLKKPgRYZLQ==} + engines: {node: '>=20', npm: '>=10'} + ts-md5@2.0.1: resolution: {integrity: sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w==} engines: {node: '>=18'} @@ -7040,6 +7714,10 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + unc-path-regex@0.1.2: + resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} + engines: {node: '>=0.10.0'} + unconfig@7.3.3: resolution: {integrity: sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==} @@ -7066,6 +7744,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unixify@1.0.0: + resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==} + engines: {node: '>=0.10.0'} + unplugin@1.16.1: resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} engines: {node: '>=14.0.0'} @@ -7088,6 +7770,15 @@ packages: url-parse-as-address@1.0.0: resolution: {integrity: sha512-1WJ8YX1Kcec9wgxy8d/ATzGP1ayO6BRnd3iB6NlM+7cOnn6U8p5PKppRTCPLobh3CSdJ4d0TdPjopzyU2KcVFw==} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + urql@5.0.2: + resolution: {integrity: sha512-hiBR9GNbMPMZpv9Yd40EMCc94d8eAkGcmt5jcrKVfp26ScjluAQLCEKetJ4SXLy5DJG59Y6gbuA+2yquzh20/w==} + peerDependencies: + '@urql/core': ^6.0.0 + react: '>= 16.8.0' + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -7336,6 +8027,10 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -7353,6 +8048,10 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -7393,6 +8092,9 @@ packages: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} + wonka@6.3.6: + resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -7497,6 +8199,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -7512,8 +8218,19 @@ packages: snapshots: + '@0no-co/graphql.web@1.2.0(graphql@16.14.0)': + optionalDependencies: + graphql: 16.14.0 + '@adobe/css-tools@4.4.4': {} + '@ardatan/relay-compiler@13.0.1(graphql@16.14.0)': + dependencies: + '@babel/runtime': 7.29.2 + graphql: 16.14.0 + immutable: 5.1.5 + invariant: 2.2.4 + '@ast-grep/napi-darwin-arm64@0.36.3': optional: true @@ -7615,6 +8332,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -7630,6 +8349,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime-corejs3@7.29.2': dependencies: core-js-pure: 3.49.0 @@ -8173,6 +8897,23 @@ snapshots: tslib: 2.8.1 optional: true + '@envelop/core@5.5.1': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@envelop/types': 5.2.1 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/instrumentation@1.0.0': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/types@5.2.1': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -8439,57 +9180,450 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.4': {} + + '@eslint/json@1.2.0': + dependencies: + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanwhocodes/momoa': 3.3.10 + natural-compare: 1.4.0 + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@fastify/busboy@2.1.1': {} + + '@fastify/busboy@3.2.0': {} + + '@figspec/components@1.0.3': + dependencies: + lit: 2.8.0 + + '@figspec/react@1.0.4(react@19.2.4)': + dependencies: + '@figspec/components': 1.0.3 + '@lit-labs/react': 1.2.1 + react: 19.2.4 + + '@graphql-codegen/add@7.0.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/cli@7.0.0(@types/node@20.19.37)(graphql@16.14.0)(typescript@5.9.3)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@graphql-codegen/client-preset': 6.0.0(graphql@16.14.0) + '@graphql-codegen/core': 6.0.0(graphql@16.14.0) + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-tools/apollo-engine-loader': 8.0.30(graphql@16.14.0) + '@graphql-tools/code-file-loader': 8.1.32(graphql@16.14.0) + '@graphql-tools/git-loader': 8.0.36(graphql@16.14.0) + '@graphql-tools/github-loader': 9.1.2(@types/node@20.19.37)(graphql@16.14.0) + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.14.0) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.14.0) + '@graphql-tools/load': 8.1.10(graphql@16.14.0) + '@graphql-tools/merge': 9.1.9(graphql@16.14.0) + '@graphql-tools/url-loader': 9.1.2(@types/node@20.19.37)(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@inquirer/prompts': 8.4.3(@types/node@20.19.37) + '@whatwg-node/fetch': 0.10.13 + chalk: 5.6.2 + cosmiconfig: 9.0.1(typescript@5.9.3) + debounce: 3.0.0 + detect-indent: 7.0.2 + graphql: 16.14.0 + graphql-config: 5.1.6(@types/node@20.19.37)(graphql@16.14.0)(typescript@5.9.3) + is-glob: 4.0.3 + jiti: 2.6.1 + json-to-pretty-yaml: 1.2.2 + listr2: 10.2.1 + log-symbols: 7.0.1 + micromatch: 4.0.8 + shell-quote: 1.8.3 + string-env-interpolation: 1.0.1 + ts-log: 3.0.2 + tslib: 2.8.1 + yaml: 2.8.3 + yargs: 18.0.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - crossws + - graphql-sock + - supports-color + - typescript + - utf-8-validate + + '@graphql-codegen/client-preset@6.0.0(graphql@16.14.0)': + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + '@graphql-codegen/add': 7.0.0(graphql@16.14.0) + '@graphql-codegen/gql-tag-operations': 6.0.0(graphql@16.14.0) + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-codegen/typed-document-node': 7.0.0(graphql@16.14.0) + '@graphql-codegen/typescript': 6.0.1(graphql@16.14.0) + '@graphql-codegen/typescript-operations': 6.0.2(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 7.0.2(graphql@16.14.0) + '@graphql-tools/documents': 1.0.1(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/core@6.0.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/gql-tag-operations@6.0.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 7.0.2(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + auto-bind: 5.0.1 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/plugin-helpers@7.0.1(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + change-case-all: 2.1.0 + common-tags: 1.8.2 + graphql: 16.14.0 + import-from: 4.0.0 + tslib: 2.8.1 + + '@graphql-codegen/schema-ast@6.0.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/typed-document-node@7.0.0(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 7.0.2(graphql@16.14.0) + auto-bind: 5.0.1 + change-case-all: 2.1.0 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/typescript-operations@6.0.2(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-codegen/schema-ast': 6.0.0(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 7.0.2(graphql@16.14.0) + auto-bind: 5.0.1 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/typescript@6.0.1(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-codegen/schema-ast': 6.0.0(graphql@16.14.0) + '@graphql-codegen/visitor-plugin-common': 7.0.2(graphql@16.14.0) + auto-bind: 5.0.1 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-codegen/visitor-plugin-common@7.0.2(graphql@16.14.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.0.1(graphql@16.14.0) + '@graphql-tools/optimize': 2.0.0(graphql@16.14.0) + '@graphql-tools/relay-operation-optimizer': 7.1.4(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + auto-bind: 5.0.1 + change-case-all: 2.1.0 + dependency-graph: 1.0.0 + graphql: 16.14.0 + graphql-tag: 2.12.6(graphql@16.14.0) + parse-filepath: 1.0.2 + tslib: 2.8.1 + + '@graphql-hive/signal@2.0.0': {} + + '@graphql-tools/apollo-engine-loader@8.0.30(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/fetch': 0.10.13 + graphql: 16.14.0 + sync-fetch: 0.6.0 + tslib: 2.8.1 + + '@graphql-tools/batch-execute@10.0.8(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/code-file-loader@8.1.32(graphql@16.14.0)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + globby: 11.1.0 + graphql: 16.14.0 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/delegate@12.0.16(graphql@16.14.0)': + dependencies: + '@graphql-tools/batch-execute': 10.0.8(graphql@16.14.0) + '@graphql-tools/executor': 1.5.3(graphql@16.14.0) + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/documents@1.0.1(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 + lodash.sortby: 4.7.0 + tslib: 2.8.1 + + '@graphql-tools/executor-common@1.0.6(graphql@16.14.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + + '@graphql-tools/executor-graphql-ws@3.1.5(graphql@16.14.0)': + dependencies: + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.14.0 + graphql-ws: 6.0.8(graphql@16.14.0)(ws@8.20.0) + isows: 1.0.7(ws@8.20.0) + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - '@fastify/websocket' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/executor-http@3.3.0(@types/node@20.19.37)(graphql@16.14.0)': + dependencies: + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + meros: 1.3.2(@types/node@20.19.37) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-legacy-ws@1.1.28(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@types/ws': 8.18.1 + graphql: 16.14.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@graphql-tools/executor@1.5.3(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/git-loader@8.0.36(graphql@16.14.0)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + is-glob: 4.0.3 + micromatch: 4.0.8 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/github-loader@9.1.2(@types/node@20.19.37)(graphql@16.14.0)': + dependencies: + '@graphql-tools/executor-http': 3.3.0(@types/node@20.19.37)(graphql@16.14.0) + '@graphql-tools/graphql-tag-pluck': 8.3.31(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + sync-fetch: 0.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@graphql-tools/graphql-file-loader@8.1.14(graphql@16.14.0)': + dependencies: + '@graphql-tools/import': 7.1.14(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + globby: 11.1.0 + graphql: 16.14.0 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/graphql-tag-pluck@8.3.31(graphql@16.14.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/import@7.1.14(graphql@16.14.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + resolve-from: 5.0.0 + tslib: 2.8.1 - '@eslint/json@1.2.0': + '@graphql-tools/json-file-loader@8.0.28(graphql@16.14.0)': dependencies: - '@eslint/core': 1.1.1 - '@eslint/plugin-kit': 0.6.1 - '@humanwhocodes/momoa': 3.3.10 - natural-compare: 1.4.0 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + globby: 11.1.0 + graphql: 16.14.0 + tslib: 2.8.1 + unixify: 1.0.0 - '@eslint/object-schema@2.1.7': {} + '@graphql-tools/load@8.1.10(graphql@16.14.0)': + dependencies: + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + p-limit: 3.1.0 + tslib: 2.8.1 - '@eslint/plugin-kit@0.4.1': + '@graphql-tools/merge@9.1.9(graphql@16.14.0)': dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 - '@eslint/plugin-kit@0.6.1': + '@graphql-tools/optimize@2.0.0(graphql@16.14.0)': dependencies: - '@eslint/core': 1.1.1 - levn: 0.4.1 + graphql: 16.14.0 + tslib: 2.8.1 - '@fast-csv/format@4.3.5': + '@graphql-tools/relay-operation-optimizer@7.1.4(graphql@16.14.0)': dependencies: - '@types/node': 14.18.63 - lodash.escaperegexp: 4.1.2 - lodash.isboolean: 3.0.3 - lodash.isequal: 4.5.0 - lodash.isfunction: 3.0.9 - lodash.isnil: 4.0.0 + '@ardatan/relay-compiler': 13.0.1(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 - '@fast-csv/parse@4.3.6': + '@graphql-tools/schema@10.0.33(graphql@16.14.0)': dependencies: - '@types/node': 14.18.63 - lodash.escaperegexp: 4.1.2 - lodash.groupby: 4.6.0 - lodash.isfunction: 3.0.9 - lodash.isnil: 4.0.0 - lodash.isundefined: 3.0.1 - lodash.uniq: 4.5.0 + '@graphql-tools/merge': 9.1.9(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + tslib: 2.8.1 - '@fastify/busboy@2.1.1': {} + '@graphql-tools/url-loader@9.1.2(@types/node@20.19.37)(graphql@16.14.0)': + dependencies: + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.14.0) + '@graphql-tools/executor-http': 3.3.0(@types/node@20.19.37)(graphql@16.14.0) + '@graphql-tools/executor-legacy-ws': 1.1.28(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) + '@types/ws': 8.18.1 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + sync-fetch: 0.6.0 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - utf-8-validate - '@figspec/components@1.0.3': + '@graphql-tools/utils@11.1.0(graphql@16.14.0)': dependencies: - lit: 2.8.0 + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.14.0 + tslib: 2.8.1 - '@figspec/react@1.0.4(react@19.2.4)': + '@graphql-tools/wrap@11.1.15(graphql@16.14.0)': dependencies: - '@figspec/components': 1.0.3 - '@lit-labs/react': 1.2.1 - react: 19.2.4 + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/schema': 10.0.33(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.0)': + dependencies: + graphql: 16.14.0 '@humanfs/core@0.19.1': {} @@ -8508,6 +9642,51 @@ snapshots: dependencies: react: 19.2.4 + '@inquirer/ansi@2.0.5': {} + + '@inquirer/checkbox@5.1.5(@types/node@20.19.37)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/confirm@6.0.13(@types/node@20.19.37)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/core@11.1.10(@types/node@20.19.37)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@20.19.37) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/editor@5.1.2(@types/node@20.19.37)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/external-editor': 3.0.0(@types/node@20.19.37) + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/expand@5.0.14(@types/node@20.19.37)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + '@inquirer/external-editor@1.0.3(@types/node@20.19.37)': dependencies: chardet: 2.1.1 @@ -8515,6 +9694,80 @@ snapshots: optionalDependencies: '@types/node': 20.19.37 + '@inquirer/external-editor@3.0.0(@types/node@20.19.37)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/figures@2.0.5': {} + + '@inquirer/input@5.0.13(@types/node@20.19.37)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/number@4.0.13(@types/node@20.19.37)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/password@5.0.13(@types/node@20.19.37)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/prompts@8.4.3(@types/node@20.19.37)': + dependencies: + '@inquirer/checkbox': 5.1.5(@types/node@20.19.37) + '@inquirer/confirm': 6.0.13(@types/node@20.19.37) + '@inquirer/editor': 5.1.2(@types/node@20.19.37) + '@inquirer/expand': 5.0.14(@types/node@20.19.37) + '@inquirer/input': 5.0.13(@types/node@20.19.37) + '@inquirer/number': 4.0.13(@types/node@20.19.37) + '@inquirer/password': 5.0.13(@types/node@20.19.37) + '@inquirer/rawlist': 5.2.9(@types/node@20.19.37) + '@inquirer/search': 4.1.9(@types/node@20.19.37) + '@inquirer/select': 5.1.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/rawlist@5.2.9(@types/node@20.19.37)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/search@4.1.9(@types/node@20.19.37)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/select@5.1.5(@types/node@20.19.37)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.10(@types/node@20.19.37) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@20.19.37) + optionalDependencies: + '@types/node': 20.19.37 + + '@inquirer/type@4.0.5(@types/node@20.19.37)': + optionalDependencies: + '@types/node': 20.19.37 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -8845,6 +10098,8 @@ snapshots: dependencies: quansync: 0.3.0 + '@repeaterjs/repeater@3.0.6': {} + '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true @@ -9864,6 +11119,13 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@urql/core@6.0.1(graphql@16.14.0)': + dependencies: + '@0no-co/graphql.web': 1.2.0(graphql@16.14.0) + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + '@vitejs/plugin-react-swc@4.3.0(vite@5.4.21(@types/node@20.19.37)(lightningcss@1.32.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -10012,6 +11274,27 @@ snapshots: '@vue/shared@3.5.31': {} + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.5 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.8.5': + dependencies: + '@fastify/busboy': 3.2.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.8.1 + abab@2.0.6: optional: true @@ -10246,6 +11529,8 @@ snapshots: asynckit@0.4.0: {} + auto-bind@5.0.1: {} + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.2 @@ -10956,6 +12241,17 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + + change-case-all@2.1.0: + dependencies: + change-case: 5.4.4 + sponge-case: 2.0.3 + swap-case: 3.0.3 + title-case: 3.0.3 + + change-case@5.4.4: {} + chardet@0.7.0: {} chardet@2.1.1: {} @@ -11004,6 +12300,8 @@ snapshots: cli-width@2.2.1: {} + cli-width@4.1.0: {} + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -11032,6 +12330,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + common-tags@1.8.2: {} + compare-versions@6.1.1: {} compress-commons@4.1.2: @@ -11088,6 +12388,10 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 + cross-inspect@1.0.1: + dependencies: + tslib: 2.8.1 + cross-spawn@5.1.0: dependencies: lru-cache: 4.1.5 @@ -11166,6 +12470,8 @@ snapshots: dependencies: assert-plus: 1.0.0 + data-uri-to-buffer@4.0.1: {} + data-urls@1.1.0: dependencies: abab: 2.0.6 @@ -11191,10 +12497,14 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dataloader@2.2.3: {} + dayjs@1.11.20: {} de-indent@1.0.2: {} + debounce@3.0.0: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -11240,6 +12550,8 @@ snapshots: delayed-stream@1.0.0: {} + dependency-graph@1.0.0: {} + dequal@2.0.3: {} detect-indent@4.0.0: @@ -11248,6 +12560,8 @@ snapshots: detect-indent@6.1.0: {} + detect-indent@7.0.2: {} + detect-libc@2.1.2: optional: true @@ -11827,6 +13141,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + exceljs@4.4.0: dependencies: archiver: 5.3.2 @@ -11876,8 +13192,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fastest-levenshtein@1.0.16: {} fastq@1.20.1: @@ -11892,6 +13218,11 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -11970,6 +13301,10 @@ snapshots: dependencies: fd-package-json: 2.0.0 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + frac@1.1.2: {} fraction.js@5.3.4: {} @@ -12144,6 +13479,41 @@ snapshots: graceful-fs@4.2.11: {} + graphql-config@5.1.6(@types/node@20.19.37)(graphql@16.14.0)(typescript@5.9.3): + dependencies: + '@graphql-tools/graphql-file-loader': 8.1.14(graphql@16.14.0) + '@graphql-tools/json-file-loader': 8.0.28(graphql@16.14.0) + '@graphql-tools/load': 8.1.10(graphql@16.14.0) + '@graphql-tools/merge': 9.1.9(graphql@16.14.0) + '@graphql-tools/url-loader': 9.1.2(@types/node@20.19.37)(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + cosmiconfig: 8.3.6(typescript@5.9.3) + graphql: 16.14.0 + jiti: 2.6.1 + minimatch: 10.2.5 + string-env-interpolation: 1.0.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - typescript + - utf-8-validate + + graphql-tag@2.12.6(graphql@16.14.0): + dependencies: + graphql: 16.14.0 + tslib: 2.8.1 + + graphql-ws@6.0.8(graphql@16.14.0)(ws@8.20.0): + dependencies: + graphql: 16.14.0 + optionalDependencies: + ws: 8.20.0 + + graphql@16.14.0: {} + grid-index@1.1.0: {} happy-dom@20.8.9: @@ -12262,11 +13632,15 @@ snapshots: immediate@3.0.6: {} + immutable@5.1.5: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 + import-from@4.0.0: {} + import-lazy@4.0.0: {} import-meta-resolve@4.2.0: {} @@ -12313,6 +13687,11 @@ snapshots: ip-regex@2.1.0: optional: true + is-absolute@1.0.0: + dependencies: + is-relative: 1.0.0 + is-windows: 1.0.2 + is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -12426,6 +13805,10 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + is-relative@1.0.0: + dependencies: + is-unc-path: 1.0.0 + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: @@ -12453,6 +13836,12 @@ snapshots: is-typedarray@1.0.0: {} + is-unc-path@1.0.0: + dependencies: + unc-path-regex: 0.1.2 + + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -12480,6 +13869,14 @@ snapshots: isexe@2.0.0: {} + isomorphic-ws@5.0.0(ws@8.20.0): + dependencies: + ws: 8.20.0 + + isows@1.0.7(ws@8.20.0): + dependencies: + ws: 8.20.0 + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -12586,6 +13983,11 @@ snapshots: json-stringify-safe@5.0.1: {} + json-to-pretty-yaml@1.2.2: + dependencies: + remedial: 1.0.8 + remove-trailing-spaces: 1.0.9 + json5@0.5.1: {} json5@1.0.2: @@ -12743,6 +14145,14 @@ snapshots: listenercount@1.0.1: {} + listr2@10.2.1: + dependencies: + cli-truncate: 5.2.0 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 10.0.0 + lit-element@3.3.3: dependencies: '@lit-labs/ssr-dom-shim': 1.5.1 @@ -12797,8 +14207,7 @@ snapshots: lodash.merge@4.6.2: {} - lodash.sortby@4.7.0: - optional: true + lodash.sortby@4.7.0: {} lodash.startcase@4.4.0: {} @@ -12812,6 +14221,19 @@ snapshots: lodash@4.18.1: {} + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + log-update@7.2.0: dependencies: ansi-escapes: 7.3.0 @@ -12867,6 +14289,8 @@ snapshots: dependencies: semver: 7.7.4 + map-cache@0.2.2: {} + map-or-similar@1.5.0: {} mapbox-gl@1.13.3: @@ -12911,6 +14335,10 @@ snapshots: merge2@1.4.1: {} + meros@1.3.2(@types/node@20.19.37): + optionalDependencies: + '@types/node': 20.19.37 + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -12985,6 +14413,8 @@ snapshots: mute-stream@0.0.8: {} + mute-stream@3.0.0: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -12998,6 +14428,8 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-domexception@1.0.0: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -13005,8 +14437,18 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-releases@2.0.37: {} + normalize-path@2.1.1: + dependencies: + remove-trailing-separator: 1.1.0 + normalize-path@3.0.0: {} npm-run-path@6.0.0: @@ -13222,6 +14664,12 @@ snapshots: dependencies: callsites: 3.1.0 + parse-filepath@1.0.2: + dependencies: + is-absolute: 1.0.0 + map-cache: 0.2.2 + path-root: 0.1.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -13246,6 +14694,12 @@ snapshots: path-parse@1.0.7: {} + path-root-regex@0.1.2: {} + + path-root@0.1.1: + dependencies: + path-root-regex: 0.1.2 + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -13827,6 +15281,12 @@ snapshots: dependencies: jsesc: 0.5.0 + remedial@1.0.8: {} + + remove-trailing-separator@1.1.0: {} + + remove-trailing-spaces@1.0.9: {} + repeating@2.0.1: dependencies: is-finite: 1.1.0 @@ -13913,6 +15373,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rimraf@2.7.1: dependencies: glob: 7.2.3 @@ -14092,6 +15554,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -14138,6 +15602,11 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slice-ansi@8.0.0: dependencies: ansi-styles: 6.2.3 @@ -14171,6 +15640,8 @@ snapshots: dependencies: through: 2.3.8 + sponge-case@2.0.3: {} + sprintf-js@1.0.3: {} ssf@0.11.2: @@ -14215,6 +15686,8 @@ snapshots: string-argv@0.3.2: {} + string-env-interpolation@1.0.1: {} + string-width@2.1.1: dependencies: is-fullwidth-code-point: 2.0.0 @@ -14455,9 +15928,17 @@ snapshots: svg-tags@1.0.0: {} + swap-case@3.0.3: {} + symbol-tree@3.2.4: optional: true + sync-fetch@0.6.0: + dependencies: + node-fetch: 3.3.2 + timeout-signal: 2.0.0 + whatwg-mimetype: 4.0.0 + table@6.9.0: dependencies: ajv: 8.18.0 @@ -14488,6 +15969,8 @@ snapshots: through@2.3.8: {} + timeout-signal@2.0.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -14510,6 +15993,10 @@ snapshots: tinyspy@3.0.2: {} + title-case@3.0.3: + dependencies: + tslib: 2.8.1 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -14549,6 +16036,8 @@ snapshots: ts-dedent@2.2.0: {} + ts-log@3.0.2: {} + ts-md5@2.0.1: {} tsconfck@3.1.6(typescript@5.9.3): @@ -14655,6 +16144,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + unc-path-regex@0.1.2: {} + unconfig@7.3.3: dependencies: '@quansync/fs': 0.1.6 @@ -14676,6 +16167,10 @@ snapshots: universalify@2.0.1: {} + unixify@1.0.0: + dependencies: + normalize-path: 2.1.1 + unplugin@1.16.1: dependencies: acorn: 8.16.0 @@ -14730,6 +16225,14 @@ snapshots: url-parse-as-address@1.0.0: {} + urlpattern-polyfill@10.1.0: {} + + urql@5.0.2(@urql/core@6.0.1(graphql@16.14.0))(react@19.2.4): + dependencies: + '@urql/core': 6.0.1(graphql@16.14.0) + react: 19.2.4 + wonka: 6.3.6 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -14941,6 +16444,8 @@ snapshots: walk-up-path@4.0.0: {} + web-streams-polyfill@3.3.3: {} + webidl-conversions@4.0.2: optional: true @@ -14956,6 +16461,8 @@ snapshots: whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -15023,6 +16530,8 @@ snapshots: wmf@1.0.2: {} + wonka@6.3.6: {} + word-wrap@1.2.5: {} word@0.3.0: {} @@ -15107,6 +16616,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 From 9d330954213f785b4a03c13bdd0405a7a80b384c Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Fri, 29 May 2026 07:44:29 +0545 Subject: [PATCH 2/3] feat(country-risk-watch): add JBA and ARC as risk event sources --- app/src/components/GoMapContainer/index.tsx | 26 +- .../GoMapContainer/styles.module.css | 2 +- app/src/components/StepGradientBar/index.tsx | 49 +++ .../StepGradientBar/styles.module.css | 11 + .../domain/RiskImminentEventMap/hdxLayers.ts | 129 +++++++ .../domain/RiskImminentEventMap/index.tsx | 178 +++++++++- .../RiskImminentEventMap/useHdxLayers.ts | 229 +++++++++++++ .../Arc/EventDetails/index.tsx | 95 ++++++ .../Arc/EventListItem/index.tsx | 42 +++ .../domain/RiskImminentEvents/Arc/index.tsx | 259 ++++++++++++++ .../Jba/EventDetails/LeadTimeChart/index.tsx | 89 +++++ .../LeadTimeChart/styles.module.css | 24 ++ .../Jba/EventDetails/index.tsx | 151 +++++++++ .../Jba/EventListItem/index.tsx | 42 +++ .../Jba/LeadTimeFilter/index.tsx | 46 +++ .../domain/RiskImminentEvents/Jba/index.tsx | 319 ++++++++++++++++++ .../domain/RiskImminentEvents/index.tsx | 74 +++- .../RiskImminentEvents/malawi/constants.ts | 12 + app/src/utils/constants.ts | 16 + .../views/CountryProfileRiskWatch/index.tsx | 6 + app/vite.config.ts | 15 + malawi-risk-watch-backend | 2 +- 22 files changed, 1790 insertions(+), 26 deletions(-) create mode 100644 app/src/components/StepGradientBar/index.tsx create mode 100644 app/src/components/StepGradientBar/styles.module.css create mode 100644 app/src/components/domain/RiskImminentEventMap/hdxLayers.ts create mode 100644 app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts create mode 100644 app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Arc/EventListItem/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Arc/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/malawi/constants.ts diff --git a/app/src/components/GoMapContainer/index.tsx b/app/src/components/GoMapContainer/index.tsx index 7ba28d6d48..91584862dc 100644 --- a/app/src/components/GoMapContainer/index.tsx +++ b/app/src/components/GoMapContainer/index.tsx @@ -51,6 +51,7 @@ interface Props { onPresentationModeChange?: (newPresentationMode: boolean) => void; children?: React.ReactNode; withFullHeight?: boolean; + layerSelection?: React.ReactNode; } function GoMapContainer(props: Props) { @@ -65,6 +66,7 @@ function GoMapContainer(props: Props) { onPresentationModeChange, children, withFullHeight, + layerSelection, } = props; const strings = useTranslation(i18n); @@ -311,15 +313,23 @@ function GoMapContainer(props: Props) { )} /> - {withPresentationMode && !printMode && !presentationMode && ( - + {withPresentationMode && !printMode && !presentationMode && ( + + )} + {layerSelection} + )} {!printMode && !presentationMode && !withoutDownloadButton && ( + {steps.map((step, index) => ( +
+
+ +
+ ))} + + ); +} + +export default StepGradientBar; diff --git a/app/src/components/StepGradientBar/styles.module.css b/app/src/components/StepGradientBar/styles.module.css new file mode 100644 index 0000000000..310c363303 --- /dev/null +++ b/app/src/components/StepGradientBar/styles.module.css @@ -0,0 +1,11 @@ +.step-gradient-bar { + width: min(30cqi, 10rem); + + .step { + flex-grow: 1; + + .swatch { + height: 0.5rem; + } + } +} diff --git a/app/src/components/domain/RiskImminentEventMap/hdxLayers.ts b/app/src/components/domain/RiskImminentEventMap/hdxLayers.ts new file mode 100644 index 0000000000..ce1f91252f --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/hdxLayers.ts @@ -0,0 +1,129 @@ +import { + COLOR_BLUE_GRADIENT_5, + COLOR_RED_GRADIENT_5, +} from '#utils/constants'; + +// Recipe table for HDX-sourced background layers rendered as admin2 choropleths. +// CSVs are admin2-keyed via the `ADM2_PCODE` column (HDX convention) and joined +// against the Mapbox `go-admin2-${iso3}-staging` tileset's feature `code`. +// +// One CSV may expose multiple metrics. Each metric becomes a flat option in the +// layer-selection radio, labelled "{dataset.label} — {metric.label}". +// +// Order is intentional (semantic grouping), not alphabetical: +// 1. hazard inputs: flood_exposure, vulnerability +// 2. capacity: facilities, access +// 3. context: demographics, rural_population +// +// Unknown HDX datasets returned by the backend are silently skipped. + +export type HdxColorRamp = readonly string[]; + +export interface HdxMetricRecipe { + column: string; + label: string; + // 'percent' assumes the source value is already on a 0-100 scale. + format?: 'number' | 'percent'; +} + +export interface HdxLayerRecipe { + datasetName: string; + label: string; + joinColumn: string; + colorRamp: HdxColorRamp; + metrics: HdxMetricRecipe[]; +} + +const ADM2_JOIN = 'ADM2_PCODE'; + +export const HDX_LAYER_RECIPES: HdxLayerRecipe[] = [ + { + datasetName: 'MWI_ADM2_flood_exposure', + label: 'Flood exposure (RP100)', + joinColumn: ADM2_JOIN, + colorRamp: COLOR_RED_GRADIENT_5, + metrics: [ + { column: 'RP100_pop_u15_30cm', label: 'Under-15 population exposed' }, + { column: 'RP100_female_pop_30cm', label: 'Female population exposed' }, + { column: 'RP100_elderly_30cm', label: 'Elderly population exposed' }, + { column: 'RP100_hospitals_30cm_pct', label: 'Hospitals at risk (%)', format: 'percent' }, + { column: 'RP100_education_30cm_pct', label: 'Education facilities at risk (%)', format: 'percent' }, + ], + }, + { + datasetName: 'MWI_ADM2_vulnerability', + label: 'Vulnerability', + joinColumn: ADM2_JOIN, + colorRamp: COLOR_RED_GRADIENT_5, + metrics: [ + { column: 'pop_u15', label: 'Under-15 population' }, + { column: 'female_pop', label: 'Female population' }, + { column: 'elderly', label: 'Elderly population' }, + { column: 'rural_pop_perc', label: 'Rural population (%)', format: 'percent' }, + ], + }, + { + datasetName: 'MWI_ADM2_facilities', + label: 'Facilities', + joinColumn: ADM2_JOIN, + colorRamp: COLOR_BLUE_GRADIENT_5, + metrics: [ + { column: 'hospitals_count', label: 'Hospitals' }, + ], + }, + { + datasetName: 'MWI_ADM2_access', + label: 'Access', + joinColumn: ADM2_JOIN, + colorRamp: COLOR_BLUE_GRADIENT_5, + metrics: [ + { column: 'access_pop_hospitals_30min', label: 'Pop. within 30 min of hospital' }, + { column: 'access_pop_primary_healthcare_30min', label: 'Pop. within 30 min of primary care' }, + { column: 'access_pop_education_5km', label: 'Pop. within 5 km of education' }, + ], + }, + { + datasetName: 'MWI_ADM2_demographics', + label: 'Demographics', + joinColumn: ADM2_JOIN, + colorRamp: COLOR_BLUE_GRADIENT_5, + metrics: [ + { column: 'pop_u15', label: 'Under-15 population' }, + { column: 'elderly', label: 'Elderly population' }, + { column: 'female_pop', label: 'Female population' }, + ], + }, + { + datasetName: 'MWI_ADM2_rural_population', + label: 'Rural population', + joinColumn: ADM2_JOIN, + colorRamp: COLOR_BLUE_GRADIENT_5, + metrics: [ + { column: 'rural_pop_perc', label: 'Rural (%)', format: 'percent' }, + { column: 'pop_u15_rural', label: 'Rural under-15 population' }, + ], + }, +]; + +export type HdxOptionKey = string; + +export interface HdxOption { + key: HdxOptionKey; + label: string; + recipe: HdxLayerRecipe; + metric: HdxMetricRecipe; +} + +// Build a flat list of `{datasetName} — {metricColumn}` options from a list of +// known dataset names returned by the backend. Datasets not in the recipe table +// are dropped. +export function buildHdxOptions(availableDatasetNames: Set): HdxOption[] { + return HDX_LAYER_RECIPES + .filter((recipe) => availableDatasetNames.has(recipe.datasetName)) + .flatMap((recipe) => recipe.metrics.map((metric) => ({ + key: `${recipe.datasetName}__${metric.column}`, + label: `${recipe.label} — ${metric.label}`, + recipe, + metric, + }))); +} diff --git a/app/src/components/domain/RiskImminentEventMap/index.tsx b/app/src/components/domain/RiskImminentEventMap/index.tsx index 7c6bede3fa..77b60e74ad 100644 --- a/app/src/components/domain/RiskImminentEventMap/index.tsx +++ b/app/src/components/domain/RiskImminentEventMap/index.tsx @@ -3,9 +3,12 @@ import { useMemo, useState, } from 'react'; +import { LayoutGridLineIcon } from '@ifrc-go/icons'; import { Container, + DropdownMenu, ListView, + RadioInput, RawList, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; @@ -25,21 +28,27 @@ import { } from '@togglecorp/re-map'; import getBuffer from '@turf/buffer'; import type { + FillLayer, + LineLayer, LngLatBoundsLike, SymbolLayer, } from 'mapbox-gl'; import GlobalMap from '#components/domain/GlobalMap'; import GoMapContainer from '#components/GoMapContainer'; +import StepGradientBar from '#components/StepGradientBar'; import { type components } from '#generated/riskTypes'; import useDebouncedValue from '#hooks/useDebouncedValue'; import { + COLOR_BLACK, + COLOR_LIGHT_GREY, COLOR_WHITE, DEFAULT_MAP_PADDING, DURATION_MAP_ZOOM, } from '#utils/constants'; import { getGeoJsonBounds } from '#utils/geo'; +import { type HdxOption } from './hdxLayers'; import LayerOptions, { type LayerOptionsValue } from './LayerOptions'; import { activeHazardPointLayer, @@ -58,6 +67,7 @@ import { trackPointOuterCircleLayer, uncertaintyConeLayer, } from './mapStyles'; +import useHdxLayers from './useHdxLayers'; import { type RiskLayerProperties } from './utils'; import i18n from './i18n.json'; @@ -101,7 +111,7 @@ export interface RiskEventDetailProps { type Footprint = GeoJSON.FeatureCollection | undefined; // FIXME: read this from common type -type ImminentEventSource = 'pdc' | 'wfpAdam' | 'gdacs' | 'meteoSwiss'; +type ImminentEventSource = 'pdc' | 'wfpAdam' | 'gdacs' | 'meteoSwiss' | 'jba' | 'arc'; interface Props { // FIXME: use props for configuration rather than @@ -117,11 +127,19 @@ interface Props { detailRenderer: React.ComponentType>; pending: boolean; sidePanelHeading: React.ReactNode; + sidePanelFilters?: React.ReactNode; bbox: LngLatBoundsLike | undefined; onActiveEventChange: (eventId: KEY | undefined) => void; activeEventExposurePending: boolean; + showLayerSelection?: boolean; + iso3ForChoropleth?: string; + activeHdxOptionKey?: string; + onActiveHdxOptionKeyChange?: (key: string | undefined) => void; } +function hdxOptionKeySelector(opt: HdxOption) { return opt.key; } +function hdxOptionLabelSelector(opt: HdxOption) { return opt.label; } + function RiskImminentEventMap< EVENT, EXPOSURE, @@ -138,10 +156,15 @@ function RiskImminentEventMap< hazardTypeSelector, footprintSelector, sidePanelHeading, + sidePanelFilters, bbox, onActiveEventChange, activeEventExposurePending, source, + showLayerSelection, + iso3ForChoropleth, + activeHdxOptionKey, + onActiveHdxOptionKeyChange, } = props; const strings = useTranslation(i18n); @@ -351,6 +374,102 @@ function RiskImminentEventMap< [allIconsLoaded], ); + const { + options: hdxOptions, + activeOption: activeHdxOption, + pcodeToColor, + bins: choroplethBins, + } = useHdxLayers(activeHdxOptionKey, Boolean(showLayerSelection)); + + const choroplethFillLayer = useMemo | undefined>(() => { + if (!iso3ForChoropleth || !pcodeToColor || pcodeToColor.size === 0) { + return undefined; + } + const matchPairs: string[] = []; + pcodeToColor.forEach((color, pcode) => { + matchPairs.push(pcode, color); + }); + const fillColor: NonNullable['fill-color'] = [ + 'match', + ['get', 'code'], + ...matchPairs, + COLOR_LIGHT_GREY, + ]; + return { + type: 'fill', + 'source-layer': `go-admin2-${iso3ForChoropleth}-staging`, + paint: { + 'fill-color': fillColor, + 'fill-opacity': 0.7, + }, + layout: { visibility: 'visible' }, + }; + }, [iso3ForChoropleth, pcodeToColor]); + + const choroplethOutlineLayer = useMemo | undefined>(() => { + if (!iso3ForChoropleth || !pcodeToColor || pcodeToColor.size === 0) { + return undefined; + } + return { + type: 'line', + 'source-layer': `go-admin2-${iso3ForChoropleth}-staging`, + paint: { + 'line-color': COLOR_BLACK, + 'line-opacity': 0.3, + 'line-width': 0.5, + }, + layout: { visibility: 'visible' }, + }; + }, [iso3ForChoropleth, pcodeToColor]); + + const layerSelectionNode = useMemo(() => { + if (!showLayerSelection || !onActiveHdxOptionKeyChange) { + return undefined; + } + return ( + } + labelStyleVariant="filled" + persistent + preferredPopupWidth={30} + > + + + ); + }, [ + showLayerSelection, + onActiveHdxOptionKeyChange, + activeHdxOptionKey, + hdxOptions, + activeHdxOption, + ]); + + const legendNode = useMemo(() => { + if (!activeHdxOption || !choroplethBins) { + return null; + } + return ( + ({ + color: bin.color, + label: bin.label, + }))} + /> + ); + }, [activeHdxOption, choroplethBins]); + return (
+ layerSelection={layerSelectionNode} + > + {legendNode} + + {iso3ForChoropleth && choroplethFillLayer && choroplethOutlineLayer && ( + + + + + )} {hazardKeys.map((key) => { const url = hazardKeyToIconMap[key]; @@ -456,6 +596,8 @@ function RiskImminentEventMap< - - - + {sidePanelFilters} + {(isDefined(events) && events.length > 0) ? ( + + + + ) : ( + isDefined(sidePanelFilters) && ( +
{strings.emptyImminentEventMessage}
+ ) + )}
); diff --git a/app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts b/app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts new file mode 100644 index 0000000000..36596573b5 --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts @@ -0,0 +1,229 @@ +import { + useEffect, + useMemo, + useState, +} from 'react'; +import { isDefined } from '@togglecorp/fujs'; +import Papa from 'papaparse'; +import { useQuery } from 'urql'; + +import { graphql } from '#generated/gql'; + +import { + buildHdxOptions, + type HdxOption, +} from './hdxLayers'; + +const HDX_DATASETS_QUERY = graphql(` + query HdxDatasets { + hdxDatasets(pagination: { limit: 9999 }) { + results { + id + datasetName + hdxUrl + fileType + } + } + } +`); + +interface ChoroplethBin { + upperBound: number; + color: string; + label: string; +} + +interface UseHdxLayersResult { + options: HdxOption[]; + optionsPending: boolean; + activeOption: HdxOption | undefined; + dataPending: boolean; + pcodeToColor: Map | undefined; + bins: ChoroplethBin[] | undefined; +} + +interface CsvRow { + [column: string]: string | undefined; +} + +const N_BINS = 5; + +function toNumber(value: string | undefined): number | undefined { + if (value === undefined || value === '') { + return undefined; + } + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +function computeQuantileBreakpoints(values: number[], bins: number): number[] { + const sorted = [...values].sort((a, b) => a - b); + const breaks: number[] = []; + if (sorted.length === 0) { + return breaks; + } + for (let i = 1; i < bins; i += 1) { + const idx = Math.min( + Math.floor((i / bins) * sorted.length), + sorted.length - 1, + ); + const v = sorted[idx]; + if (v !== undefined) { + breaks.push(v); + } + } + return breaks; +} + +function formatBinLabel(value: number, format: 'number' | 'percent' | undefined): string { + if (format === 'percent') { + // Source values are already on a 0-100 scale (per HDX convention). + return `${value.toFixed(0)}%`; + } + if (Math.abs(value) >= 1000) { + return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + } + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + +export default function useHdxLayers( + activeOptionKey: string | undefined, + enabled: boolean, +): UseHdxLayersResult { + const [{ data, fetching: optionsPending }] = useQuery({ + query: HDX_DATASETS_QUERY, + pause: !enabled, + }); + + const results = data?.hdxDatasets?.results; + + const options = useMemo(() => { + if (!results) { + return []; + } + const availableNames = new Set(results.map((r) => r.datasetName)); + return buildHdxOptions(availableNames); + }, [results]); + + const activeOption = useMemo( + () => options.find((opt) => opt.key === activeOptionKey), + [options, activeOptionKey], + ); + + const hdxUrl = useMemo(() => { + if (!activeOption) { + return undefined; + } + return results?.find( + (r) => r.datasetName === activeOption.recipe.datasetName, + )?.hdxUrl ?? undefined; + }, [results, activeOption]); + + const [csvRows, setCsvRows] = useState(undefined); + const [dataPending, setDataPending] = useState(false); + + useEffect(() => { + if (!hdxUrl) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setCsvRows(undefined); + setDataPending(false); + return; + } + + let cancelled = false; + setDataPending(true); + + Papa.parse(hdxUrl, { + download: true, + header: true, + skipEmptyLines: true, + complete: (parseResults) => { + if (cancelled) return; + setCsvRows(parseResults.data); + setDataPending(false); + }, + error: () => { + if (cancelled) return; + setCsvRows(undefined); + setDataPending(false); + }, + }); + + // eslint-disable-next-line consistent-return + return () => { cancelled = true; }; + }, [hdxUrl]); + + const { pcodeToColor, bins } = useMemo<{ + pcodeToColor: Map | undefined; + bins: ChoroplethBin[] | undefined; + }>(() => { + if (!activeOption || !csvRows) { + return { pcodeToColor: undefined, bins: undefined }; + } + + const { recipe, metric } = activeOption; + const { joinColumn } = recipe; + const valueColumn = metric.column; + + const pcodeValuePairs = csvRows + .map<{ pcode: string; value: number } | undefined>((row) => { + const pcode = row[joinColumn]; + const value = toNumber(row[valueColumn]); + if (!pcode || value === undefined) { + return undefined; + } + return { pcode, value }; + }) + .filter(isDefined); + + if (pcodeValuePairs.length === 0) { + return { pcodeToColor: undefined, bins: undefined }; + } + + const values = pcodeValuePairs.map(({ value }) => value); + const breakpoints = computeQuantileBreakpoints(values, N_BINS); + const colors = recipe.colorRamp; + const lastColor = colors[colors.length - 1] ?? '#cccccc'; + + function colorFor(value: number): string { + for (let i = 0; i < breakpoints.length; i += 1) { + const bp = breakpoints[i]; + const c = colors[i]; + if (bp !== undefined && c !== undefined && value < bp) { + return c; + } + } + return lastColor; + } + + const map = new Map(); + pcodeValuePairs.forEach(({ pcode, value }) => { + map.set(pcode, colorFor(value)); + }); + + // Each swatch is labelled with its upper bound. The last bin's upper + // bound is the dataset max. + const sortedValues = [...values].sort((a, b) => a - b); + const max = sortedValues[sortedValues.length - 1] ?? 0; + + const legendBins: ChoroplethBin[] = colors.map((color, i) => { + const upper = i === breakpoints.length ? max : (breakpoints[i] ?? max); + return { + upperBound: upper, + color, + label: formatBinLabel(upper, metric.format), + }; + }); + + return { pcodeToColor: map, bins: legendBins }; + }, [csvRows, activeOption]); + + return { + options, + optionsPending, + activeOption, + dataPending, + pcodeToColor, + bins, + }; +} diff --git a/app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx new file mode 100644 index 0000000000..ba9b31280c --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx @@ -0,0 +1,95 @@ +import { + Container, + ListView, + TextOutput, +} from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; + +import { type RiskEventDetailProps } from '#components/domain/RiskImminentEventMap'; + +import { ARC_IMPACT_THRESHOLD } from '../../malawi/constants'; +import { type ArcEvent } from '../index'; + +type Props = RiskEventDetailProps; + +function EventDetails(props: Props) { + const { + data, + pending, + children, + } = props; + + return ( + + + + {isDefined(data.rainfall) && ( + + )} + {isDefined(data.rainfallRaw) && ( + + )} + + {isDefined(data.eventRp) && ( + + )} + + + {children &&
} + {children} + + + ); +} + +export default EventDetails; diff --git a/app/src/components/domain/RiskImminentEvents/Arc/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Arc/EventListItem/index.tsx new file mode 100644 index 0000000000..7855d7a534 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Arc/EventListItem/index.tsx @@ -0,0 +1,42 @@ +import { TextOutput } from '@ifrc-go/ui'; + +import ImminentEventListItem from '#components/domain/ImminentEventListItem'; +import { type RiskEventListItemProps } from '#components/domain/RiskImminentEventMap'; + +import { type ArcEvent } from '../index'; + +type Props = RiskEventListItemProps; + +function EventListItem(props: Props) { + const { + data, + expanded, + onExpandClick, + className, + children, + } = props; + + return ( + + )} + > + {children} + + ); +} + +export default EventListItem; diff --git a/app/src/components/domain/RiskImminentEvents/Arc/index.tsx b/app/src/components/domain/RiskImminentEvents/Arc/index.tsx new file mode 100644 index 0000000000..558ed531e3 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Arc/index.tsx @@ -0,0 +1,259 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + isDefined, + isNotDefined, + unique, +} from '@togglecorp/fujs'; +import { type LngLatBoundsLike } from 'mapbox-gl'; +import { useQuery } from 'urql'; + +import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import { type RiskLayerProperties } from '#components/domain/RiskImminentEventMap/utils'; +import { graphql } from '#generated/gql'; +import { MAX_PAGE_LIMIT } from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import { ARC_IMPACT_THRESHOLD } from '../malawi/constants'; +import EventDetails from './EventDetails'; +import EventListItem from './EventListItem'; + +const ARC_RAINFALL_OBSERVATIONS_QUERY = graphql(` + query ArcRainfallObservations { + arcRainfallObservations( + order: { observationDate: DESC } + pagination: { limit: 9999 } + ) { + results { + id + observationDate + adminAreaId + adminArea { + id + pcode + ifrcId + name + } + rainfall + rainfallRaw + impact + eventRp + cellTrigger + } + } + } +`); + +export type ArcEvent = { + id: string; + observationDate: string; + adminAreaPcode: string; + adminAreaName: string; + adminAreaIfrcId: number; + rainfall: number | null; + rainfallRaw: number | null; + impact: number; + eventRp: number | null; + cellTrigger: boolean; +}; + +function keySelector(event: ArcEvent) { + return event.id; +} +function hazardTypeSelector() { + return 'FL' as const; +} + +interface BaseProps { + title: React.ReactNode; + bbox: LngLatBoundsLike | undefined; + showLayerSelection?: boolean; + activeHdxOptionKey?: string; + onActiveHdxOptionKeyChange?: (key: string | undefined) => void; +} + +type Props = BaseProps & ( + | { variant: 'global' } + | { variant: 'region'; regionId: number } + | { variant: 'country'; iso3: string } +); + +function Arc(props: Props) { + const { + title, + bbox, + variant, + showLayerSelection, + activeHdxOptionKey, + onActiveHdxOptionKeyChange, + } = props; + + // eslint-disable-next-line react/destructuring-assignment + const iso3 = variant === 'country' ? props.iso3 : undefined; + + const [{ data, fetching: pendingObservations }] = useQuery({ + query: ARC_RAINFALL_OBSERVATIONS_QUERY, + }); + + const allObservationRows = data?.arcRainfallObservations?.results; + const latestObservationDate = allObservationRows?.[0]?.observationDate; + + const events = useMemo(() => { + if (!allObservationRows || !latestObservationDate) { + return []; + } + const rows: ArcEvent[] = []; + allObservationRows.forEach((row) => { + if (String(row.observationDate) !== String(latestObservationDate)) { + return; + } + if (isNotDefined(row.impact) || Number(row.impact) < ARC_IMPACT_THRESHOLD) { + return; + } + if (isNotDefined(row.adminArea?.ifrcId)) { + // eslint-disable-next-line no-console + console.warn( + `[Arc] dropping observation row with null adminArea.ifrcId: ${row.id}`, + ); + return; + } + rows.push({ + id: row.id, + observationDate: String(row.observationDate), + adminAreaPcode: row.adminArea.pcode, + adminAreaName: row.adminArea.name, + adminAreaIfrcId: row.adminArea.ifrcId, + rainfall: isDefined(row.rainfall) ? Number(row.rainfall) : null, + rainfallRaw: isDefined(row.rainfallRaw) ? Number(row.rainfallRaw) : null, + impact: Number(row.impact), + eventRp: row.eventRp ?? null, + cellTrigger: row.cellTrigger, + }); + }); + return rows; + }, [allObservationRows, latestObservationDate]); + + const ifrcIds = useMemo( + () => unique( + events.map((e) => e.adminAreaIfrcId), + (id) => id, + ), + [events], + ); + + const { response: adminAreasResponse } = useRequest({ + skip: ifrcIds.length === 0, + url: '/api/v2/admin2/', + query: { + id__in: ifrcIds, + limit: MAX_PAGE_LIMIT, + }, + }); + + const adminAreaById = useMemo(() => { + const map = new Map['results'][number]>(); + adminAreasResponse?.results?.forEach((item) => { + if (isDefined(item?.id)) { + map.set(item.id, item); + } + }); + return map; + }, [adminAreasResponse]); + + const pointFeatureSelector = useCallback( + (event: ArcEvent): EventPointFeature | undefined => { + const admin = adminAreaById.get(event.adminAreaIfrcId); + const centroid = admin?.centroid as + | { type: 'Point'; coordinates: [number, number] } + | undefined; + if (!centroid || centroid.type !== 'Point') { + return undefined; + } + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: centroid.coordinates, + }, + properties: { + id: event.id, + hazard_type: 'FL', + }, + }; + }, + [adminAreaById], + ); + + const footprintSelector = useCallback( + ( + activeIfrcId: number | undefined, + ): GeoJSON.FeatureCollection | undefined => { + if (isNotDefined(activeIfrcId)) { + return undefined; + } + const admin = adminAreaById.get(activeIfrcId); + const bboxGeom = admin?.bbox as GeoJSON.Geometry | undefined; + if (!bboxGeom) { + return undefined; + } + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: bboxGeom, + properties: { + type: 'exposure', + severity: 'unknown', + }, + }], + }; + }, + [adminAreaById], + ); + + const [activeIfrcId, setActiveIfrcId] = useState(undefined); + + const handleActiveEventChange = useCallback( + (eventId: string | undefined) => { + if (isNotDefined(eventId)) { + setActiveIfrcId(undefined); + return; + } + const ev = events.find((e) => e.id === eventId); + setActiveIfrcId(ev?.adminAreaIfrcId); + }, + [events], + ); + + const sidePanelHeading = useMemo(() => ( + latestObservationDate ? `${title} (observed ${String(latestObservationDate)})` : title + ), [title, latestObservationDate]); + + return ( + + ); +} + +export default Arc; diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx new file mode 100644 index 0000000000..94934a60c9 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx @@ -0,0 +1,89 @@ +import { useMemo } from 'react'; +import { + ChartAxes, + ChartContainer, + Tooltip, +} from '@ifrc-go/ui'; +import { getDiscretePathDataList } from '@ifrc-go/ui/utils'; +import { _cs } from '@togglecorp/fujs'; + +import useNumericChartData from '#hooks/useNumericChartData'; +import { defaultChartMargin } from '#utils/constants'; + +import { JBA_IMPACT_THRESHOLD } from '../../../malawi/constants'; +import { type JbaEvent } from '../../index'; + +import styles from './styles.module.css'; + +interface Props { + timeline: JbaEvent[]; + activeLeadTimeDays: number; +} + +function keySelector(d: JbaEvent) { return d.id; } +function xValueSelector(d: JbaEvent) { return d.leadTimeDays ?? undefined; } +function yValueSelector(d: JbaEvent) { return d.band5Mean; } +function xAxisTickLabelSelector(v: number) { return `${v}d`; } + +function LeadTimeChart(props: Props) { + const { timeline, activeLeadTimeDays } = props; + + const chartData = useNumericChartData(timeline, { + keySelector, + xValueSelector, + yValueSelector, + xAxisTickLabelSelector, + chartMargin: defaultChartMargin, + xDomain: { min: 1, max: 10 }, + numXAxisTicks: 10, + numYAxisTicks: 4, + yValueStartsFromZero: true, + }); + + const linePath = useMemo( + () => getDiscretePathDataList(chartData.chartPoints)?.join(' ') ?? '', + [chartData.chartPoints], + ); + + const thresholdY = chartData.yScaleFn(JBA_IMPACT_THRESHOLD); + + return ( + + + + + {chartData.chartPoints.map((point) => { + const isActive = point.originalData.leadTimeDays === activeLeadTimeDays; + return ( + + + + ); + })} + + ); +} + +export default LeadTimeChart; diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css new file mode 100644 index 0000000000..fba5cf34e9 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css @@ -0,0 +1,24 @@ +.chart-container { + height: 12rem; +} + +.line { + stroke: var(--go-ui-color-primary-blue); + stroke-width: 1.5; + fill: none; +} + +.threshold { + stroke: var(--go-ui-color-red-90); + stroke-width: 1; + stroke-dasharray: 4 4; + fill: none; +} + +.point { + fill: var(--go-ui-color-primary-blue); +} + +.point-active { + fill: var(--go-ui-color-red-90); +} diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx new file mode 100644 index 0000000000..808a1d89d2 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx @@ -0,0 +1,151 @@ +import { + Container, + ListView, + TextOutput, +} from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; + +import { type RiskEventDetailProps } from '#components/domain/RiskImminentEventMap'; + +import { JBA_IMPACT_THRESHOLD } from '../../malawi/constants'; +import { type JbaEvent } from '../index'; +import LeadTimeChart from './LeadTimeChart'; + +type Props = RiskEventDetailProps; + +function EventDetails(props: Props) { + const { + data, + exposure, + pending, + children, + } = props; + + const activeLeadTimeDays = data.leadTimeDays ?? 0; + + return ( + + + {exposure && exposure.length > 1 && ( + + + + )} + + + {isDefined(data.leadTimeDays) && ( + + )} + + + + {isDefined(data.band5Median) && ( + + )} + {isDefined(data.band5P75) && ( + + )} + {isDefined(data.band5P90) && ( + + )} + {isDefined(data.band5Max) && ( + + )} + {isDefined(data.ensemblesNonzeroCount) && ( + + )} + + + + {children &&
} + {children} + + + ); +} + +export default EventDetails; diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx new file mode 100644 index 0000000000..bd8ebc91d3 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx @@ -0,0 +1,42 @@ +import { TextOutput } from '@ifrc-go/ui'; + +import ImminentEventListItem from '#components/domain/ImminentEventListItem'; +import { type RiskEventListItemProps } from '#components/domain/RiskImminentEventMap'; + +import { type JbaEvent } from '../index'; + +type Props = RiskEventListItemProps; + +function EventListItem(props: Props) { + const { + data, + expanded, + onExpandClick, + className, + children, + } = props; + + return ( + + )} + > + {children} + + ); +} + +export default EventListItem; diff --git a/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx new file mode 100644 index 0000000000..0b0199f11c --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { RadioInput } from '@ifrc-go/ui'; + +import { JBA_LEAD_TIME_DAYS } from '../../malawi/constants'; + +interface Option { + key: number; + label: string; +} + +function keySelector(o: Option) { return o.key; } +function labelSelector(o: Option) { return o.label; } + +interface Props { + value: number; + onChange: (value: number) => void; +} + +function LeadTimeFilter(props: Props) { + const { value, onChange } = props; + + const options = useMemo( + () => JBA_LEAD_TIME_DAYS.map((d) => ({ + key: d, + label: `${d} day${d === 1 ? '' : 's'}`, + })), + [], + ); + + return ( + + ); +} + +export default LeadTimeFilter; diff --git a/app/src/components/domain/RiskImminentEvents/Jba/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/index.tsx new file mode 100644 index 0000000000..c7a19a460f --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/index.tsx @@ -0,0 +1,319 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + isDefined, + isNotDefined, + unique, +} from '@togglecorp/fujs'; +import { type LngLatBoundsLike } from 'mapbox-gl'; +import { useQuery } from 'urql'; + +import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import { type RiskLayerProperties } from '#components/domain/RiskImminentEventMap/utils'; +import { graphql } from '#generated/gql'; +import { MAX_PAGE_LIMIT } from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +import { JBA_IMPACT_THRESHOLD } from '../malawi/constants'; +import EventDetails from './EventDetails'; +import EventListItem from './EventListItem'; +import LeadTimeFilter from './LeadTimeFilter'; + +const JBA_FORECAST_IMPACTS_QUERY = graphql(` + query JbaForecastImpacts { + floodForecastImpacts( + order: { forecastIssueDate: DESC } + pagination: { limit: 9999 } + ) { + results { + id + forecastIssueDate + forecastTargetDate + leadTimeDays + adminAreaId + adminArea { + id + pcode + ifrcId + name + } + band5Mean + band5Median + band5P75 + band5P90 + band5Max + ensemblesNonzeroCount + } + } + } +`); + +export type JbaEvent = { + id: string; + forecastIssueDate: string; + forecastTargetDate: string; + leadTimeDays: number | null | undefined; + adminAreaPcode: string; + adminAreaName: string; + adminAreaIfrcId: number; + band5Mean: number; + band5Median: number | null; + band5P75: number | null; + band5P90: number | null; + band5Max: number | null; + ensemblesNonzeroCount: number | null; +}; + +function keySelector(event: JbaEvent) { + return event.id; +} +function hazardTypeSelector() { + return 'FL' as const; +} + +interface BaseProps { + title: React.ReactNode; + bbox: LngLatBoundsLike | undefined; + showLayerSelection?: boolean; + activeHdxOptionKey?: string; + onActiveHdxOptionKeyChange?: (key: string | undefined) => void; + activeLeadTimeDays: number; + onActiveLeadTimeDaysChange: (value: number) => void; +} + +type Props = BaseProps & ( + | { variant: 'global' } + | { variant: 'region'; regionId: number } + | { variant: 'country'; iso3: string } +); + +function Jba(props: Props) { + const { + title, + bbox, + variant, + showLayerSelection, + activeHdxOptionKey, + onActiveHdxOptionKeyChange, + activeLeadTimeDays, + onActiveLeadTimeDaysChange, + } = props; + + // eslint-disable-next-line react/destructuring-assignment + const iso3 = variant === 'country' ? props.iso3 : undefined; + + const [{ data, fetching: pendingImpacts }] = useQuery({ + query: JBA_FORECAST_IMPACTS_QUERY, + }); + + const allImpactRows = data?.floodForecastImpacts?.results; + + // Latest forecast issue date in the response (rows are ordered DESC). + const latestIssueDate = allImpactRows?.[0]?.forecastIssueDate; + + // All rows for the latest issue date (no threshold filter). + // Used to build the per-admin timeline shown in the detail chart. + const latestRows = useMemo(() => { + if (!allImpactRows || !latestIssueDate) { + return []; + } + const rows: JbaEvent[] = []; + allImpactRows.forEach((row) => { + if (String(row.forecastIssueDate) !== String(latestIssueDate)) { + return; + } + if (isNotDefined(row.band5Mean)) { + return; + } + if (isNotDefined(row.adminArea?.ifrcId)) { + // eslint-disable-next-line no-console + console.warn( + `[Jba] dropping forecast row with null adminArea.ifrcId: ${row.id}`, + ); + return; + } + rows.push({ + id: row.id, + forecastIssueDate: String(row.forecastIssueDate), + forecastTargetDate: String(row.forecastTargetDate), + leadTimeDays: row.leadTimeDays, + adminAreaPcode: row.adminArea.pcode, + adminAreaName: row.adminArea.name, + adminAreaIfrcId: row.adminArea.ifrcId, + band5Mean: Number(row.band5Mean), + band5Median: isDefined(row.band5Median) ? Number(row.band5Median) : null, + band5P75: isDefined(row.band5P75) ? Number(row.band5P75) : null, + band5P90: isDefined(row.band5P90) ? Number(row.band5P90) : null, + band5Max: isDefined(row.band5Max) ? Number(row.band5Max) : null, + ensemblesNonzeroCount: row.ensemblesNonzeroCount ?? null, + }); + }); + return rows; + }, [allImpactRows, latestIssueDate]); + + // Per-admin timeline across all 10 lead times (sorted ascending). + const timelineByAdmin = useMemo(() => { + const map = new Map(); + latestRows.forEach((row) => { + const list = map.get(row.adminAreaIfrcId); + if (list) { + list.push(row); + } else { + map.set(row.adminAreaIfrcId, [row]); + } + }); + map.forEach((list) => { + list.sort((a, b) => (a.leadTimeDays ?? 0) - (b.leadTimeDays ?? 0)); + }); + return map; + }, [latestRows]); + + // Markers: rows at the selected lead time whose band5Mean >= threshold. + const events = useMemo( + () => latestRows.filter((e) => ( + e.leadTimeDays === activeLeadTimeDays + && e.band5Mean >= JBA_IMPACT_THRESHOLD + )), + [latestRows, activeLeadTimeDays], + ); + + const ifrcIds = useMemo( + () => unique( + events.map((e) => e.adminAreaIfrcId), + (id) => id, + ), + [events], + ); + + const { response: adminAreasResponse } = useRequest({ + skip: ifrcIds.length === 0, + url: '/api/v2/admin2/', + query: { + id__in: ifrcIds, + limit: MAX_PAGE_LIMIT, + }, + }); + + const adminAreaById = useMemo(() => { + const map = new Map['results'][number]>(); + adminAreasResponse?.results?.forEach((item) => { + if (isDefined(item?.id)) { + map.set(item.id, item); + } + }); + return map; + }, [adminAreasResponse]); + + const pointFeatureSelector = useCallback( + (event: JbaEvent): EventPointFeature | undefined => { + const admin = adminAreaById.get(event.adminAreaIfrcId); + const centroid = admin?.centroid as + | { type: 'Point'; coordinates: [number, number] } + | undefined; + if (!centroid || centroid.type !== 'Point') { + return undefined; + } + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: centroid.coordinates, + }, + properties: { + id: event.id, + hazard_type: 'FL', + }, + }; + }, + [adminAreaById], + ); + + const footprintSelector = useCallback( + ( + exposure: JbaEvent[] | undefined, + ): GeoJSON.FeatureCollection | undefined => { + const activeIfrcId = exposure?.[0]?.adminAreaIfrcId; + if (isNotDefined(activeIfrcId)) { + return undefined; + } + const admin = adminAreaById.get(activeIfrcId); + const bboxGeom = admin?.bbox as GeoJSON.Geometry | undefined; + if (!bboxGeom) { + return undefined; + } + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: bboxGeom, + properties: { + type: 'exposure', + severity: 'unknown', + }, + }], + }; + }, + [adminAreaById], + ); + + // Re-use the existing activeEventExposure plumbing to carry the active + // admin's timeline (all 10 lead times) so the detail can render the chart + // and the footprint selector can derive the ifrcId. No async fetch needed. + const [activeTimeline, setActiveTimeline] = useState(undefined); + + const handleActiveEventChange = useCallback( + (eventId: string | undefined) => { + if (isNotDefined(eventId)) { + setActiveTimeline(undefined); + return; + } + const ev = events.find((e) => e.id === eventId); + if (!ev) { + setActiveTimeline(undefined); + return; + } + setActiveTimeline(timelineByAdmin.get(ev.adminAreaIfrcId)); + }, + [events, timelineByAdmin], + ); + + const sidePanelHeading = useMemo(() => ( + latestIssueDate ? `${title} (issued ${String(latestIssueDate)})` : title + ), [title, latestIssueDate]); + + const sidePanelFilters = ( + + ); + + return ( + + ); +} + +export default Jba; diff --git a/app/src/components/domain/RiskImminentEvents/index.tsx b/app/src/components/domain/RiskImminentEvents/index.tsx index 12282dd4a5..81acd07bcf 100644 --- a/app/src/components/domain/RiskImminentEvents/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/index.tsx @@ -1,5 +1,6 @@ import { useCallback, + useEffect, useMemo, useState, } from 'react'; @@ -27,14 +28,19 @@ import { environment } from '#config'; import { type components } from '#generated/riskTypes'; import { hazardTypeToColorMap } from '#utils/domain/risk'; +import { JBA_DEFAULT_LEAD_TIME_DAYS } from './malawi/constants'; +import Arc from './Arc'; import Gdacs from './Gdacs'; +import Jba from './Jba'; import MeteoSwiss from './MeteoSwiss'; import Pdc from './Pdc'; import WfpAdam from './WfpAdam'; import i18n from './i18n.json'; -export type ImminentEventSource = 'pdc' | 'wfpAdam' | 'gdacs' | 'meteoSwiss'; +export type ImminentEventSource = 'pdc' | 'wfpAdam' | 'gdacs' | 'meteoSwiss' | 'jba' | 'arc'; + +const MALAWI_ISO3 = 'MWI'; type HazardType = components<'read'>['schemas']['CommonHazardTypeEnumKey']; type BaseProps = { @@ -57,17 +63,41 @@ type Props = BaseProps & ({ function RiskImminentEvents(props: Props) { const { className, - defaultSource = 'gdacs', ...otherProps } = props; + + const isMalawi = ( + // eslint-disable-next-line react/destructuring-assignment + props.variant === 'country' && props.iso3 === MALAWI_ISO3 + ); + // eslint-disable-next-line react/destructuring-assignment + const defaultSource: ImminentEventSource = props.defaultSource + ?? (isMalawi ? 'jba' : 'gdacs'); + const [activeView, setActiveView] = useState(defaultSource); + // HDX layer selection is lifted here so it persists across JBA <-> ARC. + const [activeHdxOptionKey, setActiveHdxOptionKey] = useState(); + const [activeLeadTimeDays, setActiveLeadTimeDays] = useState( + JBA_DEFAULT_LEAD_TIME_DAYS, + ); + + // Reset HDX selection when switching to a non-Malawi source. + useEffect(() => { + if (activeView !== 'jba' && activeView !== 'arc') { + // eslint-disable-next-line react-hooks/set-state-in-effect + setActiveHdxOptionKey(undefined); + } + }, [activeView]); + const strings = useTranslation(i18n); const handleRadioClick = useCallback((key: ImminentEventSource) => { setActiveView(key); }, []); + const showLayerSelection = isMalawi; + const riskHazards: Array<{ key: HazardType, label: string, @@ -263,6 +293,26 @@ function RiskImminentEvents(props: Props) { {strings.imminentEventsSourceMeteoSwissLabel} )} + {isMalawi && ( + + {/* FIXME: use strings */} + JBA + + )} + {isMalawi && ( + + {/* FIXME: use strings */} + ARC + + )} )} @@ -291,6 +341,26 @@ function RiskImminentEvents(props: Props) { {...otherProps} /> )} + {activeView === 'jba' && ( + + )} + {activeView === 'arc' && ( + + )} ); } diff --git a/app/src/components/domain/RiskImminentEvents/malawi/constants.ts b/app/src/components/domain/RiskImminentEvents/malawi/constants.ts new file mode 100644 index 0000000000..5bc6ab0af6 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/malawi/constants.ts @@ -0,0 +1,12 @@ +// TODO: confirm with MRCS / JBA & ARC documentation owners. +// Placeholder values — used to filter which admin areas render a flood marker. +// Until the canonical thresholds are supplied, these are intentionally loose +// so that demo runs surface something on the map. + +export const JBA_IMPACT_THRESHOLD = 0.5; +export const ARC_IMPACT_THRESHOLD = 0.5; + +// JBA delivers 10 TIFFs per day (one per lead time). Lead time options for the +// user-facing RadioInput; client-side filter applied against leadTimeDays. +export const JBA_LEAD_TIME_DAYS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; +export const JBA_DEFAULT_LEAD_TIME_DAYS = 3; diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index fc0b0b3ae6..0e19dc445d 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -77,6 +77,22 @@ export const COLOR_PRIMARY_RED = '#f5333f'; export const COLOR_ACTIVE_REGION = '#7d8b9d'; +// 5-stop sequential ramps (every other stop from the 9-stop ifrc-go/ui ramps) +export const COLOR_BLUE_GRADIENT_5 = [ + '#E0E3E7', + '#AEB7C2', + '#7D8B9D', + '#4D617A', + '#011E41', +]; +export const COLOR_RED_GRADIENT_5 = [ + '#FDD6D9', + '#FBADB2', + '#F9858C', + '#F75C65', + '#F5333F', +]; + // Import template export const FONT_FAMILY_HEADER = 'Montserrat'; diff --git a/app/src/views/CountryProfileRiskWatch/index.tsx b/app/src/views/CountryProfileRiskWatch/index.tsx index 3fcb84b094..cb3789d839 100644 --- a/app/src/views/CountryProfileRiskWatch/index.tsx +++ b/app/src/views/CountryProfileRiskWatch/index.tsx @@ -38,6 +38,8 @@ function getCurrentMonth() { return new Date().getMonth(); } +const temp_override = true; + /** @knipignore */ // eslint-disable-next-line import/prefer-default-export export function Component() { @@ -77,6 +79,10 @@ export function Component() { const hasImminentEvents = useMemo( () => { + if (temp_override) { + return true; + } + if (isNotDefined(imminentEventCountsResponse)) { return false; } diff --git a/app/vite.config.ts b/app/vite.config.ts index c61c64166f..fb1138a448 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -70,6 +70,21 @@ export default defineConfig(({ mode }) => { port: 3000, allowedHosts: ["host.docker.internal"], strictPort: true, + // Dev proxy for the Malawi Risk Watch backend. The backend has no + // CORS headers configured for http://localhost:3000, so requests + // are routed through Vite to stay same-origin in dev. + proxy: { + '/malawi-graphql': { + target: env.APP_MALAWI_RISK_WATCH_BACKEND_ORIGIN || 'http://localhost:8060', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/malawi-graphql/, '/graphql/'), + }, + '/malawi-media': { + target: env.APP_MALAWI_RISK_WATCH_BACKEND_ORIGIN || 'http://localhost:8060', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/malawi-media/, '/media'), + }, + }, }, build: { outDir: '../build', diff --git a/malawi-risk-watch-backend b/malawi-risk-watch-backend index 3d3e5ba956..db869d2d90 160000 --- a/malawi-risk-watch-backend +++ b/malawi-risk-watch-backend @@ -1 +1 @@ -Subproject commit 3d3e5ba956d11a88a50d5f4aa593aad26ed17d3e +Subproject commit db869d2d9038417b7e3d460a1b711db5a29d2232 From 8a77594c3097d656425b4f210e10cf446019e3b4 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Mon, 1 Jun 2026 08:33:03 +0545 Subject: [PATCH 3/3] WIP --- app/env.ts | 1 + app/package.json | 2 + app/src/components/Navbar/index.tsx | 7 +- .../StepGradientBar/styles.module.css | 3 +- .../ActiveCountryBaseMapLayer/index.tsx | 83 ++- .../domain/ImminentEventListItem/index.tsx | 1 - .../JbaCogRasterLayer/index.tsx | 212 +++++++ .../LayersPanel/OpacitySlider.tsx | 122 ++++ .../LayersPanel/index.tsx | 381 +++++++++++++ .../LayersPanel/styles.module.css | 142 +++++ .../RasterOverlayControl/index.tsx | 71 +++ .../domain/RiskImminentEventMap/hdxLayers.ts | 40 +- .../domain/RiskImminentEventMap/index.tsx | 533 ++++++++++++++---- .../RiskImminentEventMap/styles.module.css | 27 + .../RiskImminentEventMap/useHdxLayers.ts | 327 +++++++---- .../RiskImminentEventMap/useLocalUnits.ts | 56 ++ .../Arc/EventDetails/index.tsx | 62 +- .../domain/RiskImminentEvents/Arc/index.tsx | 47 +- .../domain/RiskImminentEvents/Gdacs/index.tsx | 3 + .../Jba/EventDetails/LeadTimeChart/index.tsx | 92 ++- .../LeadTimeChart/styles.module.css | 10 + .../Jba/EventDetails/index.tsx | 284 +++++++--- .../Jba/EventListItem/index.tsx | 2 +- .../Jba/IngestionRunFilter/index.tsx | 121 ++++ .../Jba/IngestionRunFilter/styles.module.css | 3 + .../Jba/LeadTimeFilter/index.tsx | 166 +++++- .../Jba/LeadTimeFilter/styles.module.css | 88 +++ .../domain/RiskImminentEvents/Jba/index.tsx | 255 +++++++-- .../Jba/useJbaFloodExposure.ts | 97 ++++ .../RiskImminentEvents/MeteoSwiss/index.tsx | 3 + .../domain/RiskImminentEvents/Pdc/index.tsx | 3 + .../RiskImminentEvents/WfpAdam/index.tsx | 3 + .../domain/RiskImminentEvents/index.tsx | 51 +- .../RiskImminentEvents/malawi/constants.ts | 4 + app/src/config.ts | 7 + app/src/views/FieldReportForm/index.tsx | 15 +- app/src/views/FieldReportForm/routeState.ts | 18 + docs/malawi-risk-watch/README.md | 110 ++++ .../adr/0001-malawi-sources.md | 32 ++ .../adr/0002-hdx-layer-multiselect.md | 55 ++ .../adr/0003-ingestion-run-selector.md | 35 ++ .../adr/0004-lead-time-slider.md | 37 ++ .../adr/0005-impact-figures-fan-population.md | 38 ++ .../malawi-risk-watch/adr/0006-workarounds.md | 34 ++ .../adr/0007-review-event-admin-link.md | 42 ++ docs/malawi-risk-watch/adr/README.md | 17 + docs/malawi-risk-watch/assumptions.md | 139 +++++ docs/malawi-risk-watch/figures.md | 75 +++ malawi-risk-watch-backend | 2 +- .../src/components/InputContainer/index.tsx | 2 +- pnpm-lock.yaml | 62 ++ 51 files changed, 3605 insertions(+), 417 deletions(-) create mode 100644 app/src/components/domain/RiskImminentEventMap/JbaCogRasterLayer/index.tsx create mode 100644 app/src/components/domain/RiskImminentEventMap/LayersPanel/OpacitySlider.tsx create mode 100644 app/src/components/domain/RiskImminentEventMap/LayersPanel/index.tsx create mode 100644 app/src/components/domain/RiskImminentEventMap/LayersPanel/styles.module.css create mode 100644 app/src/components/domain/RiskImminentEventMap/RasterOverlayControl/index.tsx create mode 100644 app/src/components/domain/RiskImminentEventMap/useLocalUnits.ts create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/IngestionRunFilter/index.tsx create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/IngestionRunFilter/styles.module.css create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/styles.module.css create mode 100644 app/src/components/domain/RiskImminentEvents/Jba/useJbaFloodExposure.ts create mode 100644 app/src/views/FieldReportForm/routeState.ts create mode 100644 docs/malawi-risk-watch/README.md create mode 100644 docs/malawi-risk-watch/adr/0001-malawi-sources.md create mode 100644 docs/malawi-risk-watch/adr/0002-hdx-layer-multiselect.md create mode 100644 docs/malawi-risk-watch/adr/0003-ingestion-run-selector.md create mode 100644 docs/malawi-risk-watch/adr/0004-lead-time-slider.md create mode 100644 docs/malawi-risk-watch/adr/0005-impact-figures-fan-population.md create mode 100644 docs/malawi-risk-watch/adr/0006-workarounds.md create mode 100644 docs/malawi-risk-watch/adr/0007-review-event-admin-link.md create mode 100644 docs/malawi-risk-watch/adr/README.md create mode 100644 docs/malawi-risk-watch/assumptions.md create mode 100644 docs/malawi-risk-watch/figures.md diff --git a/app/env.ts b/app/env.ts index 59759e6f7b..3b33ab5d13 100644 --- a/app/env.ts +++ b/app/env.ts @@ -24,6 +24,7 @@ export default defineConfig({ APP_TINY_API_KEY: Schema.string(), APP_RISK_API_ENDPOINT: Schema.string({ format: 'url', protocol: true }), APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT: Schema.string({ format: 'url', protocol: true, tld: false }), + APP_MALAWI_RISK_WATCH_ADMIN_URL: Schema.string.optional({ format: 'url', protocol: true, tld: false }), APP_SDT_URL: Schema.string.optional({ format: 'url', protocol: true, tld: false }), APP_POWER_BI_REPORT_ID_1: Schema.string.optional(), APP_SENTRY_DSN: Schema.string.optional(), diff --git a/app/package.json b/app/package.json index d46a42d2a9..ed9fa1f08b 100644 --- a/app/package.json +++ b/app/package.json @@ -43,6 +43,7 @@ "surge:teardown": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); surge teardown https://ifrc-go-$branch.surge.sh" }, "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", "@ifrc-go/icons": "^2.0.1", "@ifrc-go/ui": "workspace:^", "@sentry/react": "^10.0.0", @@ -57,6 +58,7 @@ "diff-match-patch": "^1.0.5", "exceljs": "^4.4.0", "file-saver": "^2.0.5", + "geotiff": "^3.0.5", "graphql": "^16.14.0", "html-to-image": "^1.11.13", "mapbox-gl": "^1.13.3", diff --git a/app/src/components/Navbar/index.tsx b/app/src/components/Navbar/index.tsx index 8f99fd95ad..4c3720b6c1 100644 --- a/app/src/components/Navbar/index.tsx +++ b/app/src/components/Navbar/index.tsx @@ -23,6 +23,7 @@ import { sdtUrl, } from '#config'; import useAuth from '#hooks/domain/useAuth'; +import { FIELD_REPORT_STATUS_EARLY_WARNING } from '#utils/constants'; import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; import CountryDropdown from './CountryDropdown'; @@ -313,7 +314,11 @@ function Navbar(props: Props) { to="fieldReportFormNew" colorVariant="primary" styleVariant="action" - state={{ earlyWarning: true }} + state={{ + initialValue: { + status: FIELD_REPORT_STATUS_EARLY_WARNING, + }, + }} withoutFullWidth > {strings.userMenuCreateEarlyActionFieldReport} diff --git a/app/src/components/StepGradientBar/styles.module.css b/app/src/components/StepGradientBar/styles.module.css index 310c363303..e8bb0a08cf 100644 --- a/app/src/components/StepGradientBar/styles.module.css +++ b/app/src/components/StepGradientBar/styles.module.css @@ -1,8 +1,9 @@ .step-gradient-bar { - width: min(30cqi, 10rem); + width: min(36cqi, 16rem); .step { flex-grow: 1; + text-align: center; .swatch { height: 0.5rem; diff --git a/app/src/components/domain/ActiveCountryBaseMapLayer/index.tsx b/app/src/components/domain/ActiveCountryBaseMapLayer/index.tsx index 86f05df792..dd2b6024e6 100644 --- a/app/src/components/domain/ActiveCountryBaseMapLayer/index.tsx +++ b/app/src/components/domain/ActiveCountryBaseMapLayer/index.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { MapLayer } from '@togglecorp/re-map'; import { + type BackgroundLayer, type FillLayer, type LineLayer, type SymbolLayer, @@ -19,6 +20,18 @@ const hiddenFillLayerOptions: Omit = { }, }; +const hiddenLineLayerOptions: Omit = { + type: 'line', + layout: { + visibility: 'none', + }, +}; + +const backgroundLayerOptions: Omit = { + type: 'background', + paint: { 'background-color': COLOR_WHITE }, +}; + interface Props { activeCountryIso3: string | undefined | null; } @@ -32,19 +45,11 @@ function ActiveCountryBaseMapLayer(props: Props) { layout: { visibility: 'visible' }, paint: { 'fill-color': [ - 'interpolate', - ['linear'], - ['zoom'], - 2, - [ - 'match', - ['get', 'iso3'], - activeCountryIso3, - COLOR_ACTIVE_REGION, - COLOR_LIGHT_GREY, - ], - 10, - COLOR_LIGHT_GREY, + 'match', + ['get', 'iso3'], + activeCountryIso3, + COLOR_ACTIVE_REGION, + COLOR_WHITE, ], }, }), @@ -56,17 +61,11 @@ function ActiveCountryBaseMapLayer(props: Props) { type: 'line', layout: { visibility: 'visible' }, paint: { - 'line-color': [ - 'match', - ['get', 'country_iso3'], - activeCountryIso3, - COLOR_WHITE, - COLOR_LIGHT_GREY, - ], + 'line-color': COLOR_WHITE, 'line-opacity': 1, }, }), - [activeCountryIso3], + [], ); const adminOneLabelLayerOptions = useMemo>( @@ -89,16 +88,54 @@ function ActiveCountryBaseMapLayer(props: Props) { [activeCountryIso3], ); + const adminZeroLabelLayerOptions = useMemo>( + () => ({ + type: 'symbol', + layout: { + visibility: 'none', + }, + }), + [], + ); + return ( <> + + + + + + + {children} diff --git a/app/src/components/domain/RiskImminentEventMap/JbaCogRasterLayer/index.tsx b/app/src/components/domain/RiskImminentEventMap/JbaCogRasterLayer/index.tsx new file mode 100644 index 0000000000..a5aa1ccbfb --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/JbaCogRasterLayer/index.tsx @@ -0,0 +1,212 @@ +import { + useEffect, + useMemo, + useState, +} from 'react'; +import { + MapLayer, + MapSource, +} from '@togglecorp/re-map'; +import { fromUrl } from 'geotiff'; +import type { RasterLayer } from 'mapbox-gl'; + +import { + COLOR_LIGHT_BLUE, + COLOR_PRIMARY_RED, +} from '#utils/constants'; + +interface Decoded { + dataUrl: string; + coordinates: [ + [number, number], + [number, number], + [number, number], + [number, number], + ]; +} + +const TARGET_OVERVIEW_PIXELS = 512 * 512; +const UPSCALE = 4; + +function hexToRgb(hex: string): [number, number, number] { + const cleaned = hex.replace('#', ''); + const r = parseInt(cleaned.slice(0, 2), 16); + const g = parseInt(cleaned.slice(2, 4), 16); + const b = parseInt(cleaned.slice(4, 6), 16); + return [r, g, b]; +} + +const [START_R, START_G, START_B] = hexToRgb(COLOR_LIGHT_BLUE); +const [END_R, END_G, END_B] = hexToRgb(COLOR_PRIMARY_RED); + +// Linear interpolation between the two endpoint colors. Alpha floor needs to +// be high enough that the pale start color remains visible against a light +// basemap, while still letting raster-opacity dial the whole thing back if +// it's too loud. +function valueToRgba(t: number): [number, number, number, number] { + const r = Math.round(START_R + (END_R - START_R) * t); + const g = Math.round(START_G + (END_G - START_G) * t); + const b = Math.round(START_B + (END_B - START_B) * t); + const a = Math.round(110 + 145 * t); + return [r, g, b, a]; +} + +interface Props { + cogUrl: string; + opacity: number; +} + +function JbaCogRasterLayer(props: Props) { + const { cogUrl, opacity } = props; + + const [decoded, setDecoded] = useState(); + + useEffect(() => { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setDecoded(undefined); + + (async () => { + try { + const tiff = await fromUrl(cogUrl); + const imageCount = await tiff.getImageCount(); + const fullImage = await tiff.getImage(0); + const bbox = fullImage.getBoundingBox(); + const west = bbox[0] ?? 0; + const south = bbox[1] ?? 0; + const east = bbox[2] ?? 0; + const north = bbox[3] ?? 0; + + // Pick the overview level closest to TARGET_OVERVIEW_PIXELS. + let renderImage = fullImage; + let bestDiff = Math.abs( + fullImage.getWidth() * fullImage.getHeight() - TARGET_OVERVIEW_PIXELS, + ); + for (let i = 1; i < imageCount; i += 1) { + // eslint-disable-next-line no-await-in-loop + const img = await tiff.getImage(i); + const pixels = img.getWidth() * img.getHeight(); + const diff = Math.abs(pixels - TARGET_OVERVIEW_PIXELS); + if (diff < bestDiff) { + bestDiff = diff; + renderImage = img; + } + } + + const rasters = await renderImage.readRasters({ samples: [0] }); + const band = rasters[0] as unknown as ArrayLike; + const width = renderImage.getWidth(); + const height = renderImage.getHeight(); + + // Per-image min/max over non-zero values for normalisation. + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < band.length; i += 1) { + const v = band[i] ?? 0; + if (v > 0) { + if (v < min) min = v; + if (v > max) max = v; + } + } + const range = max - min || 1; + + const raw = document.createElement('canvas'); + raw.width = width; + raw.height = height; + const rawCtx = raw.getContext('2d'); + if (!rawCtx) { + return; + } + const imgData = rawCtx.createImageData(width, height); + for (let i = 0; i < band.length; i += 1) { + const v = band[i] ?? 0; + const idx = i * 4; + if (v <= 0) { + imgData.data[idx + 3] = 0; + } else { + const t = Math.min((v - min) / range, 1); + const [r, g, b, a] = valueToRgba(t); + imgData.data[idx] = r; + imgData.data[idx + 1] = g; + imgData.data[idx + 2] = b; + imgData.data[idx + 3] = a; + } + } + rawCtx.putImageData(imgData, 0, 0); + + // Upscale with smoothing to soften block edges at display size. + const canvas = document.createElement('canvas'); + canvas.width = width * UPSCALE; + canvas.height = height * UPSCALE; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(raw, 0, 0, canvas.width, canvas.height); + + if (cancelled) { + return; + } + + setDecoded({ + dataUrl: canvas.toDataURL(), + coordinates: [ + [west, north], + [east, north], + [east, south], + [west, south], + ], + }); + } catch (err) { + if (!cancelled) { + // eslint-disable-next-line no-console + console.warn(`[JbaCogRasterLayer] failed to decode ${cogUrl}:`, err); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [cogUrl]); + + const sourceOptions = useMemo(() => { + if (!decoded) { + return undefined; + } + return { + type: 'image' as const, + url: decoded.dataUrl, + coordinates: decoded.coordinates, + }; + }, [decoded]); + + const rasterLayer = useMemo>(() => ({ + type: 'raster', + paint: { + 'raster-opacity': opacity, + 'raster-resampling': 'nearest', + }, + layout: { visibility: 'visible' }, + }), [opacity]); + + if (!sourceOptions) { + return null; + } + + return ( + + + + ); +} + +export default JbaCogRasterLayer; diff --git a/app/src/components/domain/RiskImminentEventMap/LayersPanel/OpacitySlider.tsx b/app/src/components/domain/RiskImminentEventMap/LayersPanel/OpacitySlider.tsx new file mode 100644 index 0000000000..cc7215491a --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/LayersPanel/OpacitySlider.tsx @@ -0,0 +1,122 @@ +import { + useCallback, + useRef, +} from 'react'; + +import styles from './styles.module.css'; + +const STEP = 5; + +function clamp(value: number) { + return Math.min(100, Math.max(0, Math.round(value))); +} + +interface Props { + // Stable identifier echoed back through onChange so callers avoid per-row closures. + name: string; + value: number; + onChange: (value: number, name: string) => void; +} + +// Thin 0–100 opacity slider (GO ships no slider primitive). Pointer drag + +// keyboard, ARIA slider semantics. +function OpacitySlider(props: Props) { + const { name, value, onChange } = props; + + const trackRef = useRef(null); + const draggingRef = useRef(false); + + const valueFromClientX = useCallback( + (clientX: number) => { + const track = trackRef.current; + if (!track) { + return value; + } + const rect = track.getBoundingClientRect(); + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return Math.round(ratio * 100); + }, + [value], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + draggingRef.current = true; + e.currentTarget.setPointerCapture?.(e.pointerId); + onChange(valueFromClientX(e.clientX), name); + }, + [name, onChange, valueFromClientX], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (draggingRef.current) { + onChange(valueFromClientX(e.clientX), name); + } + }, + [name, onChange, valueFromClientX], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + draggingRef.current = false; + e.currentTarget.releasePointerCapture?.(e.pointerId); + }, + [], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + let next: number | undefined; + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + next = value - STEP; + } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + next = value + STEP; + } else if (e.key === 'Home') { + next = 0; + } else if (e.key === 'End') { + next = 100; + } + if (next !== undefined) { + e.preventDefault(); + onChange(clamp(next), name); + } + }, + [name, value, onChange], + ); + + return ( +
+ {/* FIXME: use strings */} + Opacity +
+
+
+
+ {`${value}%`} +
+ ); +} + +export default OpacitySlider; diff --git a/app/src/components/domain/RiskImminentEventMap/LayersPanel/index.tsx b/app/src/components/domain/RiskImminentEventMap/LayersPanel/index.tsx new file mode 100644 index 0000000000..7026ae4ebd --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/LayersPanel/index.tsx @@ -0,0 +1,381 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + ChevronDownLineIcon, + CopyLineIcon, + SearchLineIcon, +} from '@ifrc-go/icons'; +import { + Container, + DropdownMenu, + InlineLayout, + Label, + ListView, + RadioInput, + RawButton, + Switch, + TextInput, +} from '@ifrc-go/ui'; +import { + _cs, + isDefined, +} from '@togglecorp/fujs'; + +import { + DEFAULT_HDX_OPACITY, + type HdxLayerSelection, + type HdxOptionGroup, + type HdxRepresentation, +} from '../hdxLayers'; +import OpacitySlider from './OpacitySlider'; + +import styles from './styles.module.css'; + +interface RepresentationOption { + key: HdxRepresentation; + label: string; +} +// FIXME: use strings +const REPRESENTATION_OPTIONS: RepresentationOption[] = [ + { key: 'choropleth', label: 'Choropleth' }, + { key: 'bubble', label: 'Bubble' }, +]; +function representationKeySelector(option: RepresentationOption) { return option.key; } +function representationLabelSelector(option: RepresentationOption) { return option.label; } + +interface LayerControlsProps { + selection: HdxLayerSelection; + onRepresentationChange: (representation: HdxRepresentation, key: string) => void; + onOpacityChange: (opacity: number, key: string) => void; +} + +// Per-layer representation + opacity controls shown beneath an active row. +function LayerControls(props: LayerControlsProps) { + const { selection, onRepresentationChange, onOpacityChange } = props; + return ( + + + + + ); +} + +interface LayerGroupProps { + group: HdxOptionGroup; + selectionByKey: Map; + normalizedSearch: string; + isOpen: boolean; + onToggleSection: (name: string) => void; + onToggle: (on: boolean, key: string) => void; + onRepresentationChange: (representation: HdxRepresentation, key: string) => void; + onOpacityChange: (opacity: number, key: string) => void; +} + +// One collapsible dataset section. A custom header button + a single +// ChevronDownLineIcon rotated via CSS (never swapped/remounted), so clicking to +// expand OR collapse never detaches the click target — detaching it would defeat +// the portaled DropdownMenu's contains()-based blur check and close the popup. +function LayerGroup(props: LayerGroupProps) { + const { + group, + selectionByKey, + normalizedSearch, + isOpen, + onToggleSection, + onToggle, + onRepresentationChange, + onOpacityChange, + } = props; + + const isSearching = normalizedSearch.length > 0; + const matchedOptions = group.options.filter((option) => ( + !isSearching + || option.metric.label.toLowerCase().includes(normalizedSearch) + || group.label.toLowerCase().includes(normalizedSearch) + )); + if (isSearching && matchedOptions.length === 0) { + return null; + } + const activeCount = group.options.filter( + (option) => selectionByKey.has(option.key), + ).length; + + return ( + + + )} + after={activeCount > 0 && ( +
{activeCount}
+ )} + > + +
+ + )} + > + {isOpen && ( + + {matchedOptions.map((option) => { + const selection = selectionByKey.get(option.key); + return ( + + + {isDefined(selection) && ( + + )} + + ); + })} + + )} +
+ ); +} + +interface Props { + optionGroups: HdxOptionGroup[]; + value: HdxLayerSelection[]; + onChange: (next: HdxLayerSelection[]) => void; + // Local units point layer (a single toggleable marker layer + opacity). + localUnitsActive: boolean; + localUnitsOpacity: number; + onLocalUnitsToggle: (active: boolean) => void; + onLocalUnitsOpacityChange: (opacity: number) => void; +} + +// Searchable, collapsible, grouped map-layers panel (design handoff "Tidy" / D1). +// Multi-select: each active data layer carries its own representation + opacity. +function LayersPanel(props: Props) { + const { + optionGroups, + value, + onChange, + localUnitsActive, + localUnitsOpacity, + onLocalUnitsToggle, + onLocalUnitsOpacityChange, + } = props; + + const [searchValue, setSearchValue] = useState(undefined); + const [expanded, setExpanded] = useState>({}); + + const selectionByKey = useMemo( + () => new Map(value.map((selection) => [selection.key, selection])), + [value], + ); + + const normalizedSearch = (searchValue ?? '').trim().toLowerCase(); + const isSearching = normalizedSearch.length > 0; + const activeCount = value.length + (localUnitsActive ? 1 : 0); + // "Local units" matches search by its own name (no metric rows to filter). + const localUnitsVisible = !isSearching || 'local units'.includes(normalizedSearch); + + const handleToggle = useCallback( + (on: boolean, key: string) => { + if (on) { + if (value.some((selection) => selection.key === key)) { + return; + } + onChange([ + ...value, + { key, representation: 'choropleth', opacity: DEFAULT_HDX_OPACITY }, + ]); + } else { + onChange(value.filter((selection) => selection.key !== key)); + } + }, + [value, onChange], + ); + + const handleRepresentationChange = useCallback( + (representation: HdxRepresentation, key: string) => { + onChange(value.map((selection) => ( + selection.key === key ? { ...selection, representation } : selection + ))); + }, + [value, onChange], + ); + + const handleOpacityChange = useCallback( + (opacity: number, key: string) => { + onChange(value.map((selection) => ( + selection.key === key ? { ...selection, opacity } : selection + ))); + }, + [value, onChange], + ); + + const handleClearAll = useCallback(() => { + onChange([]); + onLocalUnitsToggle(false); + }, [onChange, onLocalUnitsToggle]); + + const handleSectionToggle = useCallback( + (datasetName: string) => { + if (!datasetName) { + return; + } + setExpanded((prev) => { + const group = optionGroups.find((g) => g.datasetName === datasetName); + const activeN = group + ? group.options.filter((o) => selectionByKey.has(o.key)).length + : 0; + const current = prev[datasetName] ?? activeN > 0; + return { ...prev, [datasetName]: !current }; + }); + }, + [optionGroups, selectionByKey], + ); + + return ( + 0 ? `Layers (${activeCount})` : 'Layers'} + labelBefore={} + labelStyleVariant="outline" + persistent + preferredPopupWidth={26} + > + } + /> + )} + > + {/* Always mounted (hidden when empty) so clicking "Clear all" never + unmounts its own button — unmounting the click target would + detach it and close the popup. */} +
+ {/* FIXME: use strings */} + {`${activeCount} active`} + +
+ + {optionGroups.map((group) => { + const activeN = group.options.filter( + (option) => selectionByKey.has(option.key), + ).length; + const isOpen = isSearching + ? true + : (expanded[group.datasetName] ?? activeN > 0); + return ( + + ); + })} + {localUnitsVisible && ( + + + + Local units + + )} + value={localUnitsActive} + onChange={onLocalUnitsToggle} + withInvertedView + withDarkBackground + /> + {localUnitsActive && ( +
+ +
+ )} +
+ )} +
+
+
+ ); +} + +export default LayersPanel; diff --git a/app/src/components/domain/RiskImminentEventMap/LayersPanel/styles.module.css b/app/src/components/domain/RiskImminentEventMap/LayersPanel/styles.module.css new file mode 100644 index 0000000000..e59ad6e515 --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/LayersPanel/styles.module.css @@ -0,0 +1,142 @@ +.layers-panel { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-sm); + padding: var(--go-ui-spacing-sm); + + .active-summary { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--go-ui-color-text-light); + font-size: var(--go-ui-font-size-sm); + + .clear-all { + border: 0; + background: none; + cursor: pointer; + padding: 0; + color: var(--go-ui-color-primary-red); + font-family: var(--go-ui-font-family-sans-serif); + font-size: var(--go-ui-font-size-sm); + font-weight: var(--go-ui-font-weight-medium); + } + } +} + +.hidden { + display: none; +} + +/* Collapsible dataset section (custom — keeps a single, never-remounted chevron). */ +.section { + .section-header { + text-align: left; + + .chevron { + flex-shrink: 0; + transition: transform var(--go-ui-duration-transition-medium) ease-in-out; + color: var(--go-ui-color-gray-60); + } + + .chevron-expanded { + transform: rotate(180deg); + } + } +} + +/* Active-layer count badge, shown in each group header. */ +.count-badge { + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--go-ui-border-radius-full); + background-color: var(--go-ui-color-primary-red); + padding: var(--go-ui-spacing-4xs); + min-width: 1rem; + line-height: 1; + color: var(--go-ui-color-white); + font-size: var(--go-ui-font-size-2xs); +} + +/* Per-layer representation + opacity controls, shown beneath an active row. */ +.control-block { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-2xs); + margin-top: var(--go-ui-spacing-3xs); + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-gray-20); + border-radius: var(--go-ui-border-radius-md); + background-color: var(--go-ui-color-gray-10); + padding: var(--go-ui-spacing-xs); +} + +.opacity-slider { + display: flex; + align-items: center; + gap: var(--go-ui-spacing-xs); + + .opacity-label { + color: var(--go-ui-color-text-light); + font-size: var(--go-ui-font-size-sm); + } + + .opacity-track { + position: relative; + flex-grow: 1; + outline: none; + border-radius: var(--go-ui-border-radius-full); + background-color: var(--go-ui-color-gray-30); + cursor: pointer; + height: 4px; + touch-action: none; + + .opacity-fill { + position: absolute; + top: 0; + left: 0; + border-radius: var(--go-ui-border-radius-full); + background-color: var(--go-ui-color-primary-red); + height: 100%; + } + + .opacity-handle { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + border: 2px solid var(--go-ui-color-primary-red); + border-radius: var(--go-ui-border-radius-full); + box-shadow: var(--go-ui-box-shadow-sm); + background-color: var(--go-ui-color-white); + width: 14px; + height: 14px; + } + + &:focus-visible .opacity-handle { + box-shadow: + var(--go-ui-box-shadow-sm), + 0 0 0 4px color-mix(in srgb, var(--go-ui-color-primary-red) 22%, transparent); + } + } + + .opacity-value { + min-width: 2.5rem; + text-align: right; + color: var(--go-ui-color-text); + font-size: var(--go-ui-font-size-sm); + } +} + +.marker-label { + display: inline-flex; + align-items: center; + gap: var(--go-ui-spacing-2xs); + + .marker-dot { + flex-shrink: 0; + border-radius: var(--go-ui-border-radius-full); + background-color: var(--go-ui-color-primary-red); + width: 0.625rem; + height: 0.625rem; + } +} diff --git a/app/src/components/domain/RiskImminentEventMap/RasterOverlayControl/index.tsx b/app/src/components/domain/RiskImminentEventMap/RasterOverlayControl/index.tsx new file mode 100644 index 0000000000..6a0fe068cf --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/RasterOverlayControl/index.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { + ListView, + RadioInput, + Switch, +} from '@ifrc-go/ui'; + +interface OpacityOption { + key: number; + label: string; +} + +function opacityKeySelector(o: OpacityOption) { return o.key; } +function opacityLabelSelector(o: OpacityOption) { return o.label; } + +interface Props { + show: boolean; + onShowChange: (value: boolean) => void; + opacity: number; + onOpacityChange: (value: number) => void; +} + +function RasterOverlayControl(props: Props) { + const { + show, + onShowChange, + opacity, + onOpacityChange, + } = props; + + const opacityOptions = useMemo(() => ([ + { key: 0.25, label: '25%' }, + { key: 0.5, label: '50%' }, + { key: 0.75, label: '75%' }, + { key: 1, label: '100%' }, + ]), []); + + return ( + + + {show && ( + + )} + + ); +} + +export default RasterOverlayControl; diff --git a/app/src/components/domain/RiskImminentEventMap/hdxLayers.ts b/app/src/components/domain/RiskImminentEventMap/hdxLayers.ts index ce1f91252f..c377deaf65 100644 --- a/app/src/components/domain/RiskImminentEventMap/hdxLayers.ts +++ b/app/src/components/domain/RiskImminentEventMap/hdxLayers.ts @@ -7,8 +7,10 @@ import { // CSVs are admin2-keyed via the `ADM2_PCODE` column (HDX convention) and joined // against the Mapbox `go-admin2-${iso3}-staging` tileset's feature `code`. // -// One CSV may expose multiple metrics. Each metric becomes a flat option in the -// layer-selection radio, labelled "{dataset.label} — {metric.label}". +// One CSV may expose multiple metrics. Each metric becomes a toggleable option +// in the layer-selection panel, labelled "{dataset.label} — {metric.label}". +// Options are grouped by dataset (see buildHdxOptionGroups) so the panel shows +// one heading per dataset with a Switch per metric; multiple may be active. // // Order is intentional (semantic grouping), not alphabetical: // 1. hazard inputs: flood_exposure, vulnerability @@ -114,6 +116,20 @@ export interface HdxOption { metric: HdxMetricRecipe; } +// How an active data layer is drawn on the map. Independent of opacity. +export type HdxRepresentation = 'choropleth' | 'bubble'; + +export const DEFAULT_HDX_OPACITY = 80; + +// Per-active-layer selection state, lifted to the owner of the panel. Presence +// in the list means the layer is active; order is the map stacking order. +export interface HdxLayerSelection { + key: HdxOptionKey; + representation: HdxRepresentation; + // 0-100 (percent); applies to either representation. + opacity: number; +} + // Build a flat list of `{datasetName} — {metricColumn}` options from a list of // known dataset names returned by the backend. Datasets not in the recipe table // are dropped. @@ -127,3 +143,23 @@ export function buildHdxOptions(availableDatasetNames: Set): HdxOption[] metric, }))); } + +// A dataset and the options it exposes, used to render the grouped layer panel +// (one heading per dataset, one Switch per metric). Preserves recipe order. +export interface HdxOptionGroup { + datasetName: string; + label: string; + options: HdxOption[]; +} + +export function buildHdxOptionGroups(availableDatasetNames: Set): HdxOptionGroup[] { + const allOptions = buildHdxOptions(availableDatasetNames); + return HDX_LAYER_RECIPES + .filter((recipe) => availableDatasetNames.has(recipe.datasetName)) + .map((recipe) => ({ + datasetName: recipe.datasetName, + label: recipe.label, + options: allOptions.filter((opt) => opt.recipe.datasetName === recipe.datasetName), + })) + .filter((group) => group.options.length > 0); +} diff --git a/app/src/components/domain/RiskImminentEventMap/index.tsx b/app/src/components/domain/RiskImminentEventMap/index.tsx index 77b60e74ad..53504fd1da 100644 --- a/app/src/components/domain/RiskImminentEventMap/index.tsx +++ b/app/src/components/domain/RiskImminentEventMap/index.tsx @@ -1,17 +1,17 @@ import { useCallback, + useEffect, useMemo, useState, } from 'react'; -import { LayoutGridLineIcon } from '@ifrc-go/icons'; import { Container, - DropdownMenu, + Label, ListView, - RadioInput, RawList, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; +import { formatNumber } from '@ifrc-go/ui/utils'; import { isDefined, isNotDefined, @@ -28,6 +28,7 @@ import { } from '@togglecorp/re-map'; import getBuffer from '@turf/buffer'; import type { + CircleLayer, FillLayer, LineLayer, LngLatBoundsLike, @@ -42,14 +43,19 @@ import useDebouncedValue from '#hooks/useDebouncedValue'; import { COLOR_BLACK, COLOR_LIGHT_GREY, + COLOR_PRIMARY_RED, COLOR_WHITE, DEFAULT_MAP_PADDING, DURATION_MAP_ZOOM, + MAX_PAGE_LIMIT, } from '#utils/constants'; import { getGeoJsonBounds } from '#utils/geo'; +import { useRequest } from '#utils/restRequest'; -import { type HdxOption } from './hdxLayers'; +import { type HdxLayerSelection } from './hdxLayers'; +import JbaCogRasterLayer from './JbaCogRasterLayer'; import LayerOptions, { type LayerOptionsValue } from './LayerOptions'; +import LayersPanel from './LayersPanel'; import { activeHazardPointLayer, exposureFillLayer, @@ -67,7 +73,12 @@ import { trackPointOuterCircleLayer, uncertaintyConeLayer, } from './mapStyles'; +import RasterOverlayControl from './RasterOverlayControl'; import useHdxLayers from './useHdxLayers'; +import useLocalUnits, { + DEFAULT_LOCAL_UNITS_OPACITY, + type LocalUnitsSelection, +} from './useLocalUnits'; import { type RiskLayerProperties } from './utils'; import i18n from './i18n.json'; @@ -77,6 +88,20 @@ const mapImageOption = { sdf: true, }; +// Stable reference for the "no layers selected" case so useHdxLayers' memos +// don't re-run every render when activeHdxLayers is undefined. +const EMPTY_SELECTIONS: HdxLayerSelection[] = []; +const EMPTY_KEYS: string[] = []; + +// Compact number formatting for the bubble size-legend range. +const COMPACT_NUMBER_OPTIONS = { compact: true, maximumFractionDigits: 1 } as const; + +// Graduated-bubble radius range (px) for the bubble representation. +const MIN_BUBBLE_RADIUS = 4; +const MAX_BUBBLE_RADIUS = 24; + +const DEFAULT_RASTER_OPACITY = 0.75; + type CommonHazardType = components<'read'>['schemas']['CommonHazardTypeEnumKey']; const hazardKeys = Object.keys(hazardKeyToIconMap) as CommonHazardType[]; @@ -133,13 +158,21 @@ interface Props { activeEventExposurePending: boolean; showLayerSelection?: boolean; iso3ForChoropleth?: string; - activeHdxOptionKey?: string; - onActiveHdxOptionKeyChange?: (key: string | undefined) => void; + activeHdxLayers?: HdxLayerSelection[]; + onActiveHdxLayersChange?: (next: HdxLayerSelection[]) => void; + localUnits?: LocalUnitsSelection; + onLocalUnitsChange?: (next: LocalUnitsSelection) => void; + // When set, enables the raster-overlay toggle in EventDetails and + // streams the COG into a Mapbox image source. Undefined → no toggle. + cogUrl?: string; + baseLayers?: React.ReactNode; + // Rendered inline in the side-panel header (Container's headerActions). + headerActions?: React.ReactNode; + // When this changes, the open event detail and its raster controls are reset + // (e.g. JBA passes lead time + ingestion run, whose change swaps the dataset). + detailResetKey?: string | number; } -function hdxOptionKeySelector(opt: HdxOption) { return opt.key; } -function hdxOptionLabelSelector(opt: HdxOption) { return opt.label; } - function RiskImminentEventMap< EVENT, EXPOSURE, @@ -163,8 +196,14 @@ function RiskImminentEventMap< source, showLayerSelection, iso3ForChoropleth, - activeHdxOptionKey, - onActiveHdxOptionKeyChange, + activeHdxLayers, + onActiveHdxLayersChange, + localUnits, + onLocalUnitsChange, + cogUrl, + baseLayers, + headerActions, + detailResetKey, } = props; const strings = useTranslation(i18n); @@ -176,6 +215,23 @@ function RiskImminentEventMap< showTrackLine: true, showExposedArea: true, }); + const [showRaster, setShowRaster] = useState(false); + const [rasterOpacity, setRasterOpacity] = useState(DEFAULT_RASTER_OPACITY); + + // Collapse the open detail and reset its raster controls when the dataset + // behind it changes (detailResetKey = JBA lead time + ingestion run). The + // events themselves get new ids, so a stale open detail/raster must not linger. + // Deps are intentionally just [detailResetKey] — adding onActiveEventChange + // (whose identity changes when `events` refetches) would wipe the user's + // selection on every data refresh, not only on a real dataset switch. + useEffect(() => { + setActiveEventId(undefined); + setShowRaster(false); + setRasterOpacity(DEFAULT_RASTER_OPACITY); + // Keep the parent's derived detail state (e.g. active timeline) in sync. + onActiveEventChange(undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detailResetKey]); const activeEvent = useMemo( () => { if (isNotDefined(activeEventId)) { @@ -301,7 +357,6 @@ function RiskImminentEventMap< data: event, onExpandClick: setActiveEventIdSafe, expanded: activeEventId === keySelector(event), - className: styles.riskEventListItem, children: activeEventId === keySelector(event) && ( )} + {isDefined(cogUrl) && ( + + )} ), }), @@ -324,6 +387,9 @@ function RiskImminentEventMap< activeEventExposure, activeEventExposurePending, layerOptions, + cogUrl, + showRaster, + rasterOpacity, hazardTypeSelector, DetailComponent, activeEventId, @@ -374,44 +440,202 @@ function RiskImminentEventMap< [allIconsLoaded], ); + const activeSelections = activeHdxLayers ?? EMPTY_SELECTIONS; + // Keyed on a join-string so the array identity (and thus the expensive + // layer-resolution memos in useHdxLayers) only changes when the SET of + // active keys changes — not on every opacity/representation tweak. + const activeKeysSignature = activeSelections.map((selection) => selection.key).join('|'); + const activeKeys = useMemo( + () => (activeKeysSignature ? activeKeysSignature.split('|') : EMPTY_KEYS), + [activeKeysSignature], + ); + const { - options: hdxOptions, - activeOption: activeHdxOption, - pcodeToColor, - bins: choroplethBins, - } = useHdxLayers(activeHdxOptionKey, Boolean(showLayerSelection)); - - const choroplethFillLayer = useMemo | undefined>(() => { - if (!iso3ForChoropleth || !pcodeToColor || pcodeToColor.size === 0) { - return undefined; - } - const matchPairs: string[] = []; - pcodeToColor.forEach((color, pcode) => { - matchPairs.push(pcode, color); + optionGroups: hdxOptionGroups, + activeLayers: resolvedHdxLayers, + } = useHdxLayers(activeKeys, Boolean(showLayerSelection)); + + const resolvedByKey = useMemo( + () => new Map(resolvedHdxLayers.map((layer) => [layer.key, layer])), + [resolvedHdxLayers], + ); + + // Bubble representation needs admin-2 centroids (keyed by pcode === `code`); + // only fetched when at least one active layer is shown as bubbles. + const hasBubbleLayer = activeSelections.some( + (selection) => selection.representation === 'bubble', + ); + const { response: allAdmin2Response } = useRequest({ + skip: isNotDefined(iso3ForChoropleth) || !hasBubbleLayer, + url: '/api/v2/admin2/', + query: { + admin1__country__iso3: iso3ForChoropleth, + limit: MAX_PAGE_LIMIT, + }, + }); + + const centroidByPcode = useMemo(() => { + const map = new Map(); + allAdmin2Response?.results?.forEach((item) => { + const centroid = item?.centroid as + | { type: 'Point'; coordinates: [number, number] } + | undefined; + if (item?.code && centroid?.type === 'Point') { + map.set(item.code, centroid.coordinates); + } }); - const fillColor: NonNullable['fill-color'] = [ - 'match', - ['get', 'code'], - ...matchPairs, - COLOR_LIGHT_GREY, - ]; - return { - type: 'fill', - 'source-layer': `go-admin2-${iso3ForChoropleth}-staging`, - paint: { - 'fill-color': fillColor, - 'fill-opacity': 0.7, - }, - layout: { visibility: 'visible' }, - }; - }, [iso3ForChoropleth, pcodeToColor]); + return map; + }, [allAdmin2Response]); + + // One stacked admin2 fill per active *choropleth* layer, in selection order + // (last on top), each at its own user-set opacity. + const choroplethFillLayers = useMemo< + Array<{ key: string; layerOptions: Omit }> + >(() => { + if (!iso3ForChoropleth) { + return []; + } + return activeSelections + .filter((selection) => selection.representation === 'choropleth') + .map((selection): { key: string; layerOptions: Omit } | undefined => { + const layer = resolvedByKey.get(selection.key); + if (!layer || layer.pcodeToColor.size === 0) { + return undefined; + } + const matchPairs: string[] = []; + layer.pcodeToColor.forEach((color, pcode) => { + matchPairs.push(pcode, color); + }); + const fillColor: NonNullable['fill-color'] = [ + 'match', + ['get', 'code'], + ...matchPairs, + COLOR_LIGHT_GREY, + ]; + return { + key: selection.key, + layerOptions: { + type: 'fill', + // FIXME: update layer name + 'source-layer': `go-admin2-${iso3ForChoropleth}-staging`, + paint: { + 'fill-color': fillColor, + 'fill-opacity': selection.opacity / 100, + }, + layout: { visibility: 'visible' }, + }, + }; + }) + .filter(isDefined); + }, [iso3ForChoropleth, activeSelections, resolvedByKey]); + + // One graduated-bubble circle layer per active *bubble* layer: a point at + // each admin-2 centroid, radius scaled by the metric value, own opacity. + const bubbleLayers = useMemo; + layerOptions: Omit; + }>>(() => { + if (!iso3ForChoropleth || centroidByPcode.size === 0) { + return []; + } + return activeSelections + .filter((selection) => selection.representation === 'bubble') + .map((selection): { + key: string; + geoJson: GeoJSON.FeatureCollection; + layerOptions: Omit; + } | undefined => { + const layer = resolvedByKey.get(selection.key); + if (!layer || layer.pcodeToValue.size === 0) { + return undefined; + } + const features: GeoJSON.Feature[] = []; + layer.pcodeToValue.forEach((value, pcode) => { + const coordinates = centroidByPcode.get(pcode); + if (coordinates) { + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates }, + properties: { value }, + }); + } + }); + if (features.length === 0) { + return undefined; + } + const { min, max } = layer.valueRange; + const circleRadius: NonNullable['circle-radius'] = max > min + ? [ + 'interpolate', ['linear'], ['get', 'value'], + min, MIN_BUBBLE_RADIUS, + max, MAX_BUBBLE_RADIUS, + ] + : (MIN_BUBBLE_RADIUS + MAX_BUBBLE_RADIUS) / 2; + return { + key: selection.key, + geoJson: { + type: 'FeatureCollection' as const, + features, + }, + layerOptions: { + type: 'circle', + paint: { + 'circle-radius': circleRadius, + 'circle-color': layer.rampColor, + 'circle-opacity': selection.opacity / 100, + 'circle-stroke-color': COLOR_WHITE, + 'circle-stroke-width': 1, + 'circle-stroke-opacity': selection.opacity / 100, + }, + layout: { visibility: 'visible' }, + }, + }; + }) + .filter(isDefined); + }, [iso3ForChoropleth, activeSelections, resolvedByKey, centroidByPcode]); + + // Local units point layer (National Society branches/facilities) — a single + // toggleable marker layer with its own opacity (handoff point group). + const localUnitsActive = localUnits?.active ?? false; + const localUnitsOpacity = localUnits?.opacity ?? DEFAULT_LOCAL_UNITS_OPACITY; + + const { geoJson: localUnitsGeoJson } = useLocalUnits(iso3ForChoropleth, localUnitsActive); + + const localUnitsLayerOptions = useMemo>(() => ({ + type: 'circle', + paint: { + 'circle-radius': 5, + 'circle-color': COLOR_PRIMARY_RED, + 'circle-opacity': localUnitsOpacity / 100, + 'circle-stroke-color': COLOR_WHITE, + 'circle-stroke-width': 1, + 'circle-stroke-opacity': localUnitsOpacity / 100, + }, + layout: { visibility: 'visible' }, + }), [localUnitsOpacity]); + + const handleLocalUnitsToggle = useCallback( + (active: boolean) => { + onLocalUnitsChange?.({ active, opacity: localUnitsOpacity }); + }, + [onLocalUnitsChange, localUnitsOpacity], + ); + + const handleLocalUnitsOpacityChange = useCallback( + (opacity: number) => { + onLocalUnitsChange?.({ active: localUnitsActive, opacity }); + }, + [onLocalUnitsChange, localUnitsActive], + ); const choroplethOutlineLayer = useMemo | undefined>(() => { - if (!iso3ForChoropleth || !pcodeToColor || pcodeToColor.size === 0) { + if (!iso3ForChoropleth || choroplethFillLayers.length === 0) { return undefined; } return { type: 'line', + // FIXME: update layer name 'source-layer': `go-admin2-${iso3ForChoropleth}-staging`, paint: { 'line-color': COLOR_BLACK, @@ -420,60 +644,118 @@ function RiskImminentEventMap< }, layout: { visibility: 'visible' }, }; - }, [iso3ForChoropleth, pcodeToColor]); + }, [iso3ForChoropleth, choroplethFillLayers.length]); const layerSelectionNode = useMemo(() => { - if (!showLayerSelection || !onActiveHdxOptionKeyChange) { + // NOTE: not gated on hdxOptionGroups.length — the Local units point layer + // is independent of HDX and must stay toggleable while HDX is empty/loading. + if (!showLayerSelection || !onActiveHdxLayersChange) { return undefined; } return ( - } - labelStyleVariant="filled" - persistent - preferredPopupWidth={30} - > - - + ); }, [ showLayerSelection, - onActiveHdxOptionKeyChange, - activeHdxOptionKey, - hdxOptions, - activeHdxOption, + onActiveHdxLayersChange, + hdxOptionGroups, + activeSelections, + localUnitsActive, + localUnitsOpacity, + handleLocalUnitsToggle, + handleLocalUnitsOpacityChange, ]); const legendNode = useMemo(() => { - if (!activeHdxOption || !choroplethBins) { + // In selection order, joined to resolved data — so each layer's legend + // matches its representation (gradient swatches vs graduated bubbles). + const items = activeSelections + .map((selection) => { + const layer = resolvedByKey.get(selection.key); + return layer ? { selection, layer } : undefined; + }) + .filter(isDefined); + // Only advertise local units when markers are actually drawn (the layer + // itself is gated on features.length > 0). + const showLocalUnitsLegend = localUnitsActive && localUnitsGeoJson.features.length > 0; + if (items.length === 0 && !showLocalUnitsLegend) { return null; } return ( - ({ - color: bin.color, - label: bin.label, - }))} - /> + + {showLocalUnitsLegend && ( +
+ + {/* FIXME: use strings */} + +
+ )} + {items.map(({ selection, layer }) => { + const rangeLabel = `${formatNumber(layer.valueRange.min, COMPACT_NUMBER_OPTIONS) ?? ''} – ${formatNumber(layer.valueRange.max, COMPACT_NUMBER_OPTIONS) ?? ''}`; + return ( + + + {selection.representation === 'bubble' ? ( +
+ + + +
+ ) : ( + ({ + color: bin.color, + label: bin.label, + }))} + /> + )} +
+ ); + })} +
); - }, [activeHdxOption, choroplethBins]); + }, [activeSelections, resolvedByKey, localUnitsActive, localUnitsGeoJson]); return (
{legendNode} - {iso3ForChoropleth && choroplethFillLayer && choroplethOutlineLayer && ( + {iso3ForChoropleth && choroplethFillLayers.length > 0 && ( + {choroplethFillLayers.map((fillLayer) => ( + + ))} + {choroplethOutlineLayer && ( + + )} + + )} + {bubbleLayers.map((bubble) => ( + + + ))} + {iso3ForChoropleth && localUnitsActive && localUnitsGeoJson.features.length > 0 && ( + )} + {showRaster && isDefined(cogUrl) && ( + + )} {hazardKeys.map((key) => { const url = hazardKeyToIconMap[key]; @@ -596,8 +914,23 @@ function RiskImminentEventMap< getLayerName( + 'hdx-choropleth', + `hdx-choropleth-fill-${fillLayer.key}`, + true, + ), + ), getLayerName('hdx-choropleth', 'hdx-choropleth-outline', true), + ...bubbleLayers.map( + (bubble) => getLayerName( + `hdx-bubble-${bubble.key}`, + `hdx-bubble-circle-${bubble.key}`, + true, + ), + ), + getLayerName('local-units', 'local-units-circle', true), + getLayerName('jba-cog', 'jba-cog-layer', true), getLayerName('active-event-footprint', 'exposure-fill', true), getLayerName('active-event-footprint', 'exposure-fill-outline', true), getLayerName('active-event-footprint', 'uncertainty-cone', true), @@ -621,36 +954,30 @@ function RiskImminentEventMap< - {sidePanelFilters} - {(isDefined(events) && events.length > 0) ? ( - - - - ) : ( - isDefined(sidePanelFilters) && ( -
{strings.emptyImminentEventMessage}
- ) - )} + + +
); diff --git a/app/src/components/domain/RiskImminentEventMap/styles.module.css b/app/src/components/domain/RiskImminentEventMap/styles.module.css index 0315c29d73..f38420d7c7 100644 --- a/app/src/components/domain/RiskImminentEventMap/styles.module.css +++ b/app/src/components/domain/RiskImminentEventMap/styles.module.css @@ -11,6 +11,10 @@ flex-grow: 1; } + .legend-list { + max-width: 20rem; + } + @media screen and (max-width: 50rem) { flex-direction: column; height: initial; @@ -25,3 +29,26 @@ } } } + +/* Graduated-size legend for the bubble representation (size encodes value). */ +.bubble-legend { + display: flex; + align-items: center; + gap: var(--go-ui-spacing-2xs); + + .bubble-sample-small, + .bubble-sample-large { + flex-shrink: 0; + border-radius: var(--go-ui-border-radius-full); + } + + .bubble-sample-small { + width: 0.5rem; + height: 0.5rem; + } + + .bubble-sample-large { + width: 1.375rem; + height: 1.375rem; + } +} diff --git a/app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts b/app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts index 36596573b5..ad0ee1a4fe 100644 --- a/app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts +++ b/app/src/components/domain/RiskImminentEventMap/useHdxLayers.ts @@ -1,8 +1,10 @@ import { useEffect, useMemo, + useRef, useState, } from 'react'; +import { formatNumber } from '@ifrc-go/ui/utils'; import { isDefined } from '@togglecorp/fujs'; import Papa from 'papaparse'; import { useQuery } from 'urql'; @@ -10,11 +12,13 @@ import { useQuery } from 'urql'; import { graphql } from '#generated/gql'; import { + buildHdxOptionGroups, buildHdxOptions, type HdxOption, + type HdxOptionGroup, } from './hdxLayers'; -const HDX_DATASETS_QUERY = graphql(` +export const HDX_DATASETS_QUERY = graphql(` query HdxDatasets { hdxDatasets(pagination: { limit: 9999 }) { results { @@ -33,13 +37,26 @@ interface ChoroplethBin { label: string; } +// One resolved layer, ready to render: a pcode → color map for the choropleth +// `match` expression, the raw pcode → value map + value range for the graduated +// bubble representation, plus the legend bins. `rampColor` is the strong end of +// the metric's colour ramp, used as the bubble fill. +export interface ActiveHdxLayer { + key: string; + label: string; + pcodeToColor: Map; + pcodeToValue: Map; + valueRange: { min: number; max: number }; + rampColor: string; + bins: ChoroplethBin[]; +} + interface UseHdxLayersResult { options: HdxOption[]; + optionGroups: HdxOptionGroup[]; optionsPending: boolean; - activeOption: HdxOption | undefined; + activeLayers: ActiveHdxLayer[]; dataPending: boolean; - pcodeToColor: Map | undefined; - bins: ChoroplethBin[] | undefined; } interface CsvRow { @@ -80,14 +97,117 @@ function formatBinLabel(value: number, format: 'number' | 'percent' | undefined) // Source values are already on a 0-100 scale (per HDX convention). return `${value.toFixed(0)}%`; } - if (Math.abs(value) >= 1000) { - return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + // Compact notation (e.g. 12K, 1.2M) keeps the legend swatches narrow. + return formatNumber(value, { compact: true, maximumFractionDigits: 1 }) ?? ''; +} + +// Resolve a single metric option against its parsed CSV into a pcode → color +// map and legend bins. Returns undefined when the CSV yields no usable values. +function computeChoropleth( + option: HdxOption, + csvRows: CsvRow[], +): Omit | undefined { + const { recipe, metric } = option; + const { joinColumn } = recipe; + const valueColumn = metric.column; + + const pcodeValuePairs = csvRows + .map<{ pcode: string; value: number } | undefined>((row) => { + const pcode = row[joinColumn]; + const value = toNumber(row[valueColumn]); + if (!pcode || value === undefined) { + return undefined; + } + return { pcode, value }; + }) + .filter(isDefined); + + if (pcodeValuePairs.length === 0) { + return undefined; } - return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + + const values = pcodeValuePairs.map(({ value }) => value); + const colors = recipe.colorRamp; + const lastColor = colors[colors.length - 1] ?? '#cccccc'; + + const sortedValues = [...values].sort((a, b) => a - b); + const min = sortedValues[0] ?? 0; + const max = sortedValues[sortedValues.length - 1] ?? 0; + + // Raw value per pcode + the metric's value range — used by the graduated + // bubble representation to scale circle radius. rampColor is the strong end + // of the ramp, used as the bubble fill. + const pcodeToValue = new Map(); + pcodeValuePairs.forEach(({ pcode, value }) => { + pcodeToValue.set(pcode, value); + }); + const valueRange = { min, max }; + + // Degenerate case: a uniform metric (most commonly an all-zero count column, + // e.g. hospitals_count where no district has a hospital) has no spread to + // bin. Quantile breakpoints would all equal that single value and the strict + // `value < bp` test below would fall through to lastColor, painting every + // area the darkest ramp color and a `0,0,0,0,0` legend — reading as "max + // everywhere". Collapse to one uniform swatch + single legend bin instead. + if (max === min) { + const uniformColor = colors[0] ?? lastColor; + const pcodeToColor = new Map(); + pcodeValuePairs.forEach(({ pcode }) => { + pcodeToColor.set(pcode, uniformColor); + }); + return { + pcodeToColor, + pcodeToValue, + valueRange, + rampColor: uniformColor, + bins: [{ + upperBound: max, + color: uniformColor, + label: formatBinLabel(max, metric.format), + }], + }; + } + + const breakpoints = computeQuantileBreakpoints(values, N_BINS); + + function colorFor(value: number): string { + for (let i = 0; i < breakpoints.length; i += 1) { + const bp = breakpoints[i]; + const c = colors[i]; + if (bp !== undefined && c !== undefined && value < bp) { + return c; + } + } + return lastColor; + } + + const pcodeToColor = new Map(); + pcodeValuePairs.forEach(({ pcode, value }) => { + pcodeToColor.set(pcode, colorFor(value)); + }); + + // Each swatch is labelled with its upper bound. The last bin's upper + // bound is the dataset max. + const bins: ChoroplethBin[] = colors.map((color, i) => { + const upper = i === breakpoints.length ? max : (breakpoints[i] ?? max); + return { + upperBound: upper, + color, + label: formatBinLabel(upper, metric.format), + }; + }); + + return { + pcodeToColor, + pcodeToValue, + valueRange, + rampColor: lastColor, + bins, + }; } export default function useHdxLayers( - activeOptionKey: string | undefined, + activeOptionKeys: string[], enabled: boolean, ): UseHdxLayersResult { const [{ data, fetching: optionsPending }] = useQuery({ @@ -105,125 +225,110 @@ export default function useHdxLayers( return buildHdxOptions(availableNames); }, [results]); - const activeOption = useMemo( - () => options.find((opt) => opt.key === activeOptionKey), - [options, activeOptionKey], - ); - - const hdxUrl = useMemo(() => { - if (!activeOption) { - return undefined; + const optionGroups = useMemo(() => { + if (!results) { + return []; } - return results?.find( - (r) => r.datasetName === activeOption.recipe.datasetName, - )?.hdxUrl ?? undefined; - }, [results, activeOption]); + const availableNames = new Set(results.map((r) => r.datasetName)); + return buildHdxOptionGroups(availableNames); + }, [results]); - const [csvRows, setCsvRows] = useState(undefined); - const [dataPending, setDataPending] = useState(false); + // Active options, in selection order (drives stacking + legend order). + const activeOptions = useMemo( + () => activeOptionKeys + .map((key) => options.find((opt) => opt.key === key)) + .filter(isDefined), + [activeOptionKeys, options], + ); - useEffect(() => { - if (!hdxUrl) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setCsvRows(undefined); - setDataPending(false); - return; - } + // Dataset name → HDX CSV url, for the datasets backing the active options. + const datasetUrlByName = useMemo(() => { + const map = new Map(); + results?.forEach((r) => { + if (isDefined(r.hdxUrl)) { + map.set(r.datasetName, r.hdxUrl); + } + }); + return map; + }, [results]); - let cancelled = false; - setDataPending(true); - - Papa.parse(hdxUrl, { - download: true, - header: true, - skipEmptyLines: true, - complete: (parseResults) => { - if (cancelled) return; - setCsvRows(parseResults.data); - setDataPending(false); - }, - error: () => { - if (cancelled) return; - setCsvRows(undefined); - setDataPending(false); - }, + // Distinct CSV urls we need (several metrics may share one dataset CSV). + const activeUrls = useMemo(() => { + const urls = new Set(); + activeOptions.forEach((opt) => { + const url = datasetUrlByName.get(opt.recipe.datasetName); + if (url) { + urls.add(url); + } }); + return Array.from(urls); + }, [activeOptions, datasetUrlByName]); + const activeUrlsKey = activeUrls.join('|'); - // eslint-disable-next-line consistent-return - return () => { cancelled = true; }; - }, [hdxUrl]); + // Parsed CSVs, cached by url so toggling a layer back on is instant and we + // never re-download a CSV shared by two active metrics. Never evicted — + // the dataset count is small and bounded by the recipe table. + const [csvByUrl, setCsvByUrl] = useState>({}); + const inFlightRef = useRef>(new Set()); + const [pendingCount, setPendingCount] = useState(0); - const { pcodeToColor, bins } = useMemo<{ - pcodeToColor: Map | undefined; - bins: ChoroplethBin[] | undefined; - }>(() => { - if (!activeOption || !csvRows) { - return { pcodeToColor: undefined, bins: undefined }; + useEffect(() => { + if (!enabled) { + return; } + activeUrls.forEach((url) => { + if (csvByUrl[url] || inFlightRef.current.has(url)) { + return; + } + inFlightRef.current.add(url); + setPendingCount((count) => count + 1); - const { recipe, metric } = activeOption; - const { joinColumn } = recipe; - const valueColumn = metric.column; + Papa.parse(url, { + download: true, + header: true, + skipEmptyLines: true, + complete: (parseResults) => { + inFlightRef.current.delete(url); + setPendingCount((count) => count - 1); + setCsvByUrl((prev) => ({ ...prev, [url]: parseResults.data })); + }, + error: () => { + inFlightRef.current.delete(url); + setPendingCount((count) => count - 1); + }, + }); + }); + // activeUrlsKey is a stable join of activeUrls; csvByUrl guards re-fetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeUrlsKey, csvByUrl, enabled]); - const pcodeValuePairs = csvRows - .map<{ pcode: string; value: number } | undefined>((row) => { - const pcode = row[joinColumn]; - const value = toNumber(row[valueColumn]); - if (!pcode || value === undefined) { + const activeLayers = useMemo( + () => activeOptions + .map((option) => { + const url = datasetUrlByName.get(option.recipe.datasetName); + const csvRows = url ? csvByUrl[url] : undefined; + if (!csvRows) { return undefined; } - return { pcode, value }; - }) - .filter(isDefined); - - if (pcodeValuePairs.length === 0) { - return { pcodeToColor: undefined, bins: undefined }; - } - - const values = pcodeValuePairs.map(({ value }) => value); - const breakpoints = computeQuantileBreakpoints(values, N_BINS); - const colors = recipe.colorRamp; - const lastColor = colors[colors.length - 1] ?? '#cccccc'; - - function colorFor(value: number): string { - for (let i = 0; i < breakpoints.length; i += 1) { - const bp = breakpoints[i]; - const c = colors[i]; - if (bp !== undefined && c !== undefined && value < bp) { - return c; + const choropleth = computeChoropleth(option, csvRows); + if (!choropleth) { + return undefined; } - } - return lastColor; - } - - const map = new Map(); - pcodeValuePairs.forEach(({ pcode, value }) => { - map.set(pcode, colorFor(value)); - }); - - // Each swatch is labelled with its upper bound. The last bin's upper - // bound is the dataset max. - const sortedValues = [...values].sort((a, b) => a - b); - const max = sortedValues[sortedValues.length - 1] ?? 0; - - const legendBins: ChoroplethBin[] = colors.map((color, i) => { - const upper = i === breakpoints.length ? max : (breakpoints[i] ?? max); - return { - upperBound: upper, - color, - label: formatBinLabel(upper, metric.format), - }; - }); - - return { pcodeToColor: map, bins: legendBins }; - }, [csvRows, activeOption]); + return { + key: option.key, + label: option.label, + ...choropleth, + }; + }) + .filter(isDefined), + [activeOptions, datasetUrlByName, csvByUrl], + ); return { options, + optionGroups, optionsPending, - activeOption, - dataPending, - pcodeToColor, - bins, + activeLayers, + dataPending: pendingCount > 0, }; } diff --git a/app/src/components/domain/RiskImminentEventMap/useLocalUnits.ts b/app/src/components/domain/RiskImminentEventMap/useLocalUnits.ts new file mode 100644 index 0000000000..f89e81d547 --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/useLocalUnits.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { isDefined } from '@togglecorp/fujs'; + +import { MAX_PAGE_LIMIT } from '#utils/constants'; +import { useRequest } from '#utils/restRequest'; + +type LocationGeoJson = { + type: 'Point'; + coordinates: [number, number]; +}; + +// Local-units point layer state (a single togglable marker layer + opacity), +// modelled like the HDX point groups in the design handoff. +export interface LocalUnitsSelection { + active: boolean; + opacity: number; +} + +export const DEFAULT_LOCAL_UNITS_OPACITY = 90; + +// Fetches a country's National Society local units (public endpoint, same data +// source as the NS LocalUnitsMap) and shapes them into a point FeatureCollection. +export default function useLocalUnits(iso3: string | undefined, enabled: boolean) { + const { response, pending } = useRequest({ + skip: !enabled || !iso3, + url: '/api/v2/public-local-units/', + query: { + country__iso3: iso3, + limit: MAX_PAGE_LIMIT, + }, + }); + + const geoJson = useMemo>( + () => ({ + type: 'FeatureCollection', + features: (response?.results ?? []) + .map((unit) => { + const geometry = unit.location_geojson as unknown as + | LocationGeoJson + | undefined; + if (!geometry || geometry.type !== 'Point') { + return undefined; + } + return { + type: 'Feature' as const, + geometry, + properties: { id: unit.id }, + }; + }) + .filter(isDefined), + }), + [response], + ); + + return { geoJson, pending }; +} diff --git a/app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx index ba9b31280c..7b79d39f64 100644 --- a/app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Arc/EventDetails/index.tsx @@ -3,15 +3,40 @@ import { ListView, TextOutput, } from '@ifrc-go/ui'; +import { encodeDate } from '@ifrc-go/ui/utils'; import { isDefined } from '@togglecorp/fujs'; import { type RiskEventDetailProps } from '#components/domain/RiskImminentEventMap'; +import Link from '#components/Link'; +import useAuth from '#hooks/domain/useAuth'; +import useCountry from '#hooks/domain/useCountry'; +import usePermissions from '#hooks/domain/usePermissions'; +import { FIELD_REPORT_STATUS_EVENT } from '#utils/constants'; +import { type PartialFormValue } from '#views/FieldReportForm/common'; -import { ARC_IMPACT_THRESHOLD } from '../../malawi/constants'; +import { + ARC_IMPACT_THRESHOLD, + DISASTER_FLOOD_ID, +} from '../../malawi/constants'; import { type ArcEvent } from '../index'; type Props = RiskEventDetailProps; +function buildDescription(data: ArcEvent) { + const parts = [ + `ARC rainfall observation on ${data.observationDate} for ${data.adminAreaName}:`, + ]; + if (isDefined(data.rainfall)) { + parts.push(`rainfall ${data.rainfall.toFixed(2)} mm,`); + } + parts.push(`impact ${data.impact.toFixed(3)}`); + if (isDefined(data.eventRp)) { + parts.push(`, return period ${data.eventRp} years`); + } + parts.push('.'); + return parts.join(' '); +} + function EventDetails(props: Props) { const { data, @@ -19,6 +44,26 @@ function EventDetails(props: Props) { children, } = props; + const { isAuthenticated } = useAuth(); + const { isGuestUser } = usePermissions(); + const malawiCountry = useCountry({ iso3: 'MWI' }); + + const canCreateReport = isAuthenticated && !isGuestUser; + const initialReportValue: PartialFormValue | undefined = ( + malawiCountry?.id && data.districtId + ? { + status: FIELD_REPORT_STATUS_EVENT, + country: malawiCountry.id, + dtype: DISASTER_FLOOD_ID, + districts: [data.districtId], + start_date: encodeDate(data.observationDate), + // FIXME: use strings + title: `Flood observation — ${data.adminAreaName}`, + description: buildDescription(data), + } + : undefined + ); + return ( @@ -85,6 +130,21 @@ function EventDetails(props: Props) { strongValue withLightBackground /> + {canCreateReport && ( + + {/* FIXME: use strings */} + Create field report + + )} {children &&
} {children} diff --git a/app/src/components/domain/RiskImminentEvents/Arc/index.tsx b/app/src/components/domain/RiskImminentEvents/Arc/index.tsx index 558ed531e3..06643e64dd 100644 --- a/app/src/components/domain/RiskImminentEvents/Arc/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Arc/index.tsx @@ -12,6 +12,8 @@ import { type LngLatBoundsLike } from 'mapbox-gl'; import { useQuery } from 'urql'; import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import { type HdxLayerSelection } from '#components/domain/RiskImminentEventMap/hdxLayers'; +import { type LocalUnitsSelection } from '#components/domain/RiskImminentEventMap/useLocalUnits'; import { type RiskLayerProperties } from '#components/domain/RiskImminentEventMap/utils'; import { graphql } from '#generated/gql'; import { MAX_PAGE_LIMIT } from '#utils/constants'; @@ -58,6 +60,9 @@ export type ArcEvent = { impact: number; eventRp: number | null; cellTrigger: boolean; + // Populated once the admin2 REST fetch resolves (joined by adminAreaIfrcId). + districtId: number | undefined; + districtName: string | undefined; }; function keySelector(event: ArcEvent) { @@ -71,8 +76,11 @@ interface BaseProps { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; showLayerSelection?: boolean; - activeHdxOptionKey?: string; - onActiveHdxOptionKeyChange?: (key: string | undefined) => void; + activeHdxLayers?: HdxLayerSelection[]; + onActiveHdxLayersChange?: (next: HdxLayerSelection[]) => void; + localUnits?: LocalUnitsSelection; + onLocalUnitsChange?: (next: LocalUnitsSelection) => void; + baseLayers?: React.ReactNode; } type Props = BaseProps & ( @@ -87,8 +95,11 @@ function Arc(props: Props) { bbox, variant, showLayerSelection, - activeHdxOptionKey, - onActiveHdxOptionKeyChange, + activeHdxLayers, + onActiveHdxLayersChange, + localUnits, + onLocalUnitsChange, + baseLayers, } = props; // eslint-disable-next-line react/destructuring-assignment @@ -101,7 +112,7 @@ function Arc(props: Props) { const allObservationRows = data?.arcRainfallObservations?.results; const latestObservationDate = allObservationRows?.[0]?.observationDate; - const events = useMemo(() => { + const eventsRaw = useMemo(() => { if (!allObservationRows || !latestObservationDate) { return []; } @@ -131,6 +142,8 @@ function Arc(props: Props) { impact: Number(row.impact), eventRp: row.eventRp ?? null, cellTrigger: row.cellTrigger, + districtId: undefined, + districtName: undefined, }); }); return rows; @@ -138,10 +151,10 @@ function Arc(props: Props) { const ifrcIds = useMemo( () => unique( - events.map((e) => e.adminAreaIfrcId), + eventsRaw.map((e) => e.adminAreaIfrcId), (id) => id, ), - [events], + [eventsRaw], ); const { response: adminAreasResponse } = useRequest({ @@ -163,6 +176,19 @@ function Arc(props: Props) { return map; }, [adminAreasResponse]); + // Enrich with admin1 district info once the admin2 REST call lands. + const events = useMemo( + () => eventsRaw.map((row) => { + const admin = adminAreaById.get(row.adminAreaIfrcId); + return { + ...row, + districtId: admin?.district_id, + districtName: admin?.district_name, + }; + }), + [eventsRaw, adminAreaById], + ); + const pointFeatureSelector = useCallback( (event: ArcEvent): EventPointFeature | undefined => { const admin = adminAreaById.get(event.adminAreaIfrcId); @@ -250,8 +276,11 @@ function Arc(props: Props) { onActiveEventChange={handleActiveEventChange} showLayerSelection={showLayerSelection} iso3ForChoropleth={iso3} - activeHdxOptionKey={activeHdxOptionKey} - onActiveHdxOptionKeyChange={onActiveHdxOptionKeyChange} + activeHdxLayers={activeHdxLayers} + onActiveHdxLayersChange={onActiveHdxLayersChange} + localUnits={localUnits} + onLocalUnitsChange={onLocalUnitsChange} + baseLayers={baseLayers} /> ); } diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx index 92b54181f7..289ada2a86 100644 --- a/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx @@ -129,6 +129,7 @@ function getLayerProperties( type BaseProps = { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; + baseLayers?: React.ReactNode; } type Props = BaseProps & ({ @@ -146,6 +147,7 @@ function Gdacs(props: Props) { title, bbox, variant, + baseLayers, } = props; const { @@ -284,6 +286,7 @@ function Gdacs(props: Props) { activeEventExposure={exposureResponse} activeEventExposurePending={exposureResponsePending} onActiveEventChange={handleActiveEventChange} + baseLayers={baseLayers} /> ); } diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx index 94934a60c9..b32d68aa51 100644 --- a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/index.tsx @@ -5,7 +5,10 @@ import { Tooltip, } from '@ifrc-go/ui'; import { getDiscretePathDataList } from '@ifrc-go/ui/utils'; -import { _cs } from '@togglecorp/fujs'; +import { + _cs, + isDefined, +} from '@togglecorp/fujs'; import useNumericChartData from '#hooks/useNumericChartData'; import { defaultChartMargin } from '#utils/constants'; @@ -25,9 +28,38 @@ function xValueSelector(d: JbaEvent) { return d.leadTimeDays ?? undefined; } function yValueSelector(d: JbaEvent) { return d.band5Mean; } function xAxisTickLabelSelector(v: number) { return `${v}d`; } +// Closed area path between a lower and an upper series across the chart points, +// skipping points where either bound is undefined. Returns undefined if there +// are fewer than two usable points. +function buildBandPath( + points: { x: number; lower: number | null | undefined; upper: number | null | undefined }[], + yScaleFn: (value: number) => number, +) { + const usable = points.filter( + (d): d is { x: number; lower: number; upper: number } => ( + isDefined(d.lower) && isDefined(d.upper) + ), + ); + if (usable.length < 2) { + return undefined; + } + const upperEdge = usable.map((d) => `${d.x},${yScaleFn(d.upper)}`); + const lowerEdge = [...usable].reverse().map((d) => `${d.x},${yScaleFn(d.lower)}`); + return `M ${upperEdge.join(' L ')} L ${lowerEdge.join(' L ')} Z`; +} + function LeadTimeChart(props: Props) { const { timeline, activeLeadTimeDays } = props; + // Scale the y-axis to cover the full ensemble spread (the max line / band), + // not just the mean — otherwise the fan would clip. + const yDomainMax = useMemo(() => { + const values = timeline.flatMap( + (d) => [d.band5Max, d.band5P90, d.band5Mean].filter(isDefined), + ); + return values.length > 0 ? Math.max(...values) : undefined; + }, [timeline]); + const chartData = useNumericChartData(timeline, { keySelector, xValueSelector, @@ -35,6 +67,7 @@ function LeadTimeChart(props: Props) { xAxisTickLabelSelector, chartMargin: defaultChartMargin, xDomain: { min: 1, max: 10 }, + yDomain: isDefined(yDomainMax) ? { min: 0, max: yDomainMax } : undefined, numXAxisTicks: 10, numYAxisTicks: 4, yValueStartsFromZero: true, @@ -45,6 +78,31 @@ function LeadTimeChart(props: Props) { [chartData.chartPoints], ); + // Outer envelope (median → max) and inner likely range (median → P90). + const envelopePath = useMemo( + () => buildBandPath( + chartData.chartPoints.map((p) => ({ + x: p.x, + lower: p.originalData.band5Median, + upper: p.originalData.band5Max, + })), + chartData.yScaleFn, + ), + [chartData.chartPoints, chartData.yScaleFn], + ); + + const likelyPath = useMemo( + () => buildBandPath( + chartData.chartPoints.map((p) => ({ + x: p.x, + lower: p.originalData.band5Median, + upper: p.originalData.band5P90, + })), + chartData.yScaleFn, + ), + [chartData.chartPoints, chartData.yScaleFn], + ); + const thresholdY = chartData.yScaleFn(JBA_IMPACT_THRESHOLD); return ( @@ -53,6 +111,18 @@ function LeadTimeChart(props: Props) { chartData={chartData} > + {isDefined(envelopePath) && ( + + )} + {isDefined(likelyPath) && ( + + )} {chartData.chartPoints.map((point) => { - const isActive = point.originalData.leadTimeDays === activeLeadTimeDays; + const ev = point.originalData; + const isActive = ev.leadTimeDays === activeLeadTimeDays; return ( +
{`Mean: ${ev.band5Mean.toFixed(2)} m`}
+ {isDefined(ev.band5Median) && ( +
{`Median: ${ev.band5Median.toFixed(2)} m`}
+ )} + {isDefined(ev.band5P90) && ( +
{`P90: ${ev.band5P90.toFixed(2)} m`}
+ )} + {isDefined(ev.band5Max) && ( +
{`Max: ${ev.band5Max.toFixed(2)} m`}
+ )} + + )} />
); diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css index fba5cf34e9..2ed598f769 100644 --- a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/LeadTimeChart/styles.module.css @@ -2,6 +2,16 @@ height: 12rem; } +.band-envelope { + fill: color-mix(in srgb, var(--go-ui-color-primary-blue) 10%, transparent); + stroke: none; +} + +.band-likely { + fill: color-mix(in srgb, var(--go-ui-color-primary-blue) 22%, transparent); + stroke: none; +} + .line { stroke: var(--go-ui-color-primary-blue); stroke-width: 1.5; diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx index 808a1d89d2..2298911679 100644 --- a/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventDetails/index.tsx @@ -1,18 +1,37 @@ import { Container, + InfoPopup, + KeyFigure, ListView, TextOutput, } from '@ifrc-go/ui'; +import { encodeDate } from '@ifrc-go/ui/utils'; import { isDefined } from '@togglecorp/fujs'; import { type RiskEventDetailProps } from '#components/domain/RiskImminentEventMap'; +import Link from '#components/Link'; +import { malawiRiskWatchAdminUrl } from '#config'; +import useAuth from '#hooks/domain/useAuth'; +import useCountry from '#hooks/domain/useCountry'; +import usePermissions from '#hooks/domain/usePermissions'; +import { FIELD_REPORT_STATUS_EARLY_WARNING } from '#utils/constants'; +import { type PartialFormValue } from '#views/FieldReportForm/common'; -import { JBA_IMPACT_THRESHOLD } from '../../malawi/constants'; +import { DISASTER_FLOOD_ID } from '../../malawi/constants'; import { type JbaEvent } from '../index'; import LeadTimeChart from './LeadTimeChart'; type Props = RiskEventDetailProps; +function buildDescription(data: JbaEvent) { + const leadDay = data.leadTimeDays ?? '?'; + const mean = data.band5Mean.toFixed(3); + const nonZero = isDefined(data.ensemblesNonzeroCount) + ? ` (${data.ensemblesNonzeroCount} of 51 ensembles non-zero)` + : ''; + return `JBA flood forecast (issued ${data.forecastIssueDate}) for ${data.adminAreaName} crosses the impact threshold at lead day ${leadDay}. Ensemble-mean impact: ${mean}${nonZero}.`; +} + function EventDetails(props: Props) { const { data, @@ -23,15 +42,42 @@ function EventDetails(props: Props) { const activeLeadTimeDays = data.leadTimeDays ?? 0; + const { isAuthenticated } = useAuth(); + const { isGuestUser } = usePermissions(); + const malawiCountry = useCountry({ iso3: 'MWI' }); + + const canCreateReport = isAuthenticated && !isGuestUser; + const initialReportValue: PartialFormValue | undefined = ( + malawiCountry?.id && data.districtId + ? { + status: FIELD_REPORT_STATUS_EARLY_WARNING, + country: malawiCountry.id, + dtype: DISASTER_FLOOD_ID, + districts: [data.districtId], + start_date: encodeDate(data.forecastTargetDate), + // FIXME: use strings + title: `Flood forecast — ${data.adminAreaName}`, + description: buildDescription(data), + } + : undefined + ); + return ( - + + {exposure && exposure.length > 1 && ( )} - - - {isDefined(data.leadTimeDays) && ( - - )} +
+ Ensemble statistics of the JBA-forecast flood depth (in + metres) across the 51 ensemble members, for this district + at the selected lead time. +
+
+ Mean and Median are central estimates. P75 and P90 are the + 75th and 90th percentiles — a P90 well above the mean means + a minority of members predict much deeper flooding. Max is + the deepest single member. +
+
+ )} + /> + )} > - - + {isDefined(data.band5Median) && ( - )} {isDefined(data.band5P75) && ( - )} {isDefined(data.band5P90) && ( - )} {isDefined(data.band5Max) && ( - )} - {isDefined(data.ensemblesNonzeroCount) && ( - +
+ {isDefined(data.floodExposure) && ( + +
+ HDX 1-in-100-year (RP100) flood-exposed population at + 30 cm depth for this district. +
+
+ Static exposure context — not matched to this + forecast's depth or lead time. The groups + overlap and are not additive. +
+ + )} /> )} - + > + + {isDefined(data.floodExposure.popU15) && ( + + )} + {isDefined(data.floodExposure.elderly) && ( + + )} + {isDefined(data.floodExposure.female) && ( + + )} + {isDefined(data.floodExposure.childrenU5) && ( + + )} + +
+ )} + {isDefined(data.ensemblesNonzeroCount) && ( + + )} + {canCreateReport && ( + + + {/* FIXME: use strings */} + Create early warning report + + + {/* FIXME: use strings */} + Review event + - - {children &&
} + )} {children} diff --git a/app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx index bd8ebc91d3..b4027c7e1c 100644 --- a/app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Jba/EventListItem/index.tsx @@ -23,7 +23,7 @@ function EventListItem(props: Props) { expanded={expanded} onExpandClick={onExpandClick} heading={data.adminAreaName} - description={( + description={!expanded && ( + {/* FIXME: use strings */} + + + + {isDefined(run.forecastIssueTime) && ( + + )} + {isDefined(run.completedAt) && ( + + )} + + )} + /> + ); +} + +interface Props { + runs: JbaIngestionRun[] | undefined; + value: string | undefined; + onChange: (value: string) => void; + activeRun: JbaIngestionRun | undefined; + pending?: boolean; +} + +function IngestionRunFilter(props: Props) { + const { + runs, + value, + onChange, + activeRun, + pending, + } = props; + + const handleChange = useCallback( + (newValue: string) => { + onChange(newValue); + }, + [onChange], + ); + + return ( + : undefined} + /> + ); +} + +export default IngestionRunFilter; diff --git a/app/src/components/domain/RiskImminentEvents/Jba/IngestionRunFilter/styles.module.css b/app/src/components/domain/RiskImminentEvents/Jba/IngestionRunFilter/styles.module.css new file mode 100644 index 0000000000..22ad66f30b --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/IngestionRunFilter/styles.module.css @@ -0,0 +1,3 @@ +.ingestion-run-select { + width: 12rem; +} diff --git a/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx index 0b0199f11c..098a55e9ec 100644 --- a/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/index.tsx @@ -1,45 +1,163 @@ -import { useMemo } from 'react'; -import { RadioInput } from '@ifrc-go/ui'; +import { + useCallback, + useRef, +} from 'react'; +import { InputLabel } from '@ifrc-go/ui'; +import { _cs } from '@togglecorp/fujs'; import { JBA_LEAD_TIME_DAYS } from '../../malawi/constants'; -interface Option { - key: number; - label: string; +import styles from './styles.module.css'; + +const MIN_DAY = JBA_LEAD_TIME_DAYS[0]; +const MAX_DAY = JBA_LEAD_TIME_DAYS[JBA_LEAD_TIME_DAYS.length - 1] ?? MIN_DAY; +// Number of gaps between steps (9 for a 1-10 range); drives the position math. +const STEP_COUNT = JBA_LEAD_TIME_DAYS.length - 1; + +function clamp(value: number) { + return Math.min(MAX_DAY, Math.max(MIN_DAY, value)); } -function keySelector(o: Option) { return o.key; } -function labelSelector(o: Option) { return o.label; } +// Percentage position [0, 100] of a day value along the track. +function pctOf(value: number) { + return ((value - MIN_DAY) / STEP_COUNT) * 100; +} + +function dayLabel(day: number) { + return `${day} ${day > 1 ? 'days' : 'day'}`; +} interface Props { value: number; onChange: (value: number) => void; } +// Forecast lead-time selector: a horizontal slider with a numbered 1-10 scale +// (design handoff "B2"). Replaces the previous radio-button group. function LeadTimeFilter(props: Props) { const { value, onChange } = props; - const options = useMemo( - () => JBA_LEAD_TIME_DAYS.map((d) => ({ - key: d, - label: `${d} day${d === 1 ? '' : 's'}`, - })), + const trackRef = useRef(null); + const draggingRef = useRef(false); + + const dayFromClientX = useCallback( + (clientX: number) => { + const track = trackRef.current; + if (!track) { + return value; + } + const rect = track.getBoundingClientRect(); + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return Math.round(ratio * STEP_COUNT) + MIN_DAY; + }, + [value], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + draggingRef.current = true; + e.currentTarget.setPointerCapture?.(e.pointerId); + onChange(dayFromClientX(e.clientX)); + }, + [dayFromClientX, onChange], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (draggingRef.current) { + onChange(dayFromClientX(e.clientX)); + } + }, + [dayFromClientX, onChange], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + draggingRef.current = false; + e.currentTarget.releasePointerCapture?.(e.pointerId); + }, [], ); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + let next: number | undefined; + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + next = value - 1; + } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + next = value + 1; + } else if (e.key === 'Home') { + next = MIN_DAY; + } else if (e.key === 'End') { + next = MAX_DAY; + } + if (next !== undefined) { + e.preventDefault(); + onChange(clamp(next)); + } + }, + [value, onChange], + ); + + const handleNumberClick = useCallback( + (e: React.MouseEvent) => { + onChange(Number(e.currentTarget.dataset.day)); + }, + [onChange], + ); + + const pct = pctOf(value); + return ( - +
+ {/* FIXME: use strings */} + + Forecast lead time + +
+
+
+
+
+
+ {JBA_LEAD_TIME_DAYS.map((day, index) => ( + + ))} +
+
+
); } diff --git a/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/styles.module.css b/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/styles.module.css new file mode 100644 index 0000000000..cdc76f6cc2 --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/LeadTimeFilter/styles.module.css @@ -0,0 +1,88 @@ +.lead-time-filter { + display: flex; + flex-direction: column; + gap: var(--go-ui-spacing-2xs); + + .slider { + /* vertical room for the 20px handle to overflow the track; horizontal + room so the edge handle and the centred "1"/"10" labels stay in bounds */ + padding: 0.625rem 0.75rem 0; + } + + .track { + position: relative; + outline: none; + border-radius: var(--go-ui-border-radius-full); + background-color: var(--go-ui-color-gray-30); + cursor: pointer; + height: 6px; + touch-action: none; + + /* extend the touch/click hit area beyond the 6px visual track */ + &::before { + position: absolute; + inset: -0.625rem 0; + content: ""; + } + + .fill { + position: absolute; + top: 0; + left: 0; + border-radius: var(--go-ui-border-radius-full); + background-color: var(--go-ui-color-primary-red); + height: 100%; + } + + .handle { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + border: 2.5px solid var(--go-ui-color-primary-red); + border-radius: var(--go-ui-border-radius-full); + box-shadow: var(--go-ui-box-shadow-sm); + background-color: var(--go-ui-color-white); + width: 20px; + height: 20px; + } + + &:focus-visible .handle { + box-shadow: + var(--go-ui-box-shadow-sm), + 0 0 0 4px color-mix(in srgb, var(--go-ui-color-primary-red) 22%, transparent); + } + } + + .number-row { + position: relative; + margin-top: 0.875rem; + height: 1.25rem; + + .number { + position: absolute; + top: 0; + transform: translateX(-50%); + border: 0; + background: none; + cursor: pointer; + padding: 0 0.125rem; + line-height: 1; + white-space: nowrap; + color: var(--go-ui-color-gray-60); + font-family: var(--go-ui-font-family-sans-serif); + font-size: var(--go-ui-font-size-sm); + font-weight: var(--go-ui-font-weight-medium); + + &.active { + color: var(--go-ui-color-primary-red); + font-weight: var(--go-ui-font-weight-semibold); + } + + &:focus-visible { + outline: 2px solid var(--go-ui-color-primary-red); + outline-offset: 2px; + border-radius: var(--go-ui-border-radius-sm); + } + } + } +} diff --git a/app/src/components/domain/RiskImminentEvents/Jba/index.tsx b/app/src/components/domain/RiskImminentEvents/Jba/index.tsx index c7a19a460f..786955bf5e 100644 --- a/app/src/components/domain/RiskImminentEvents/Jba/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Jba/index.tsx @@ -12,6 +12,8 @@ import { type LngLatBoundsLike } from 'mapbox-gl'; import { useQuery } from 'urql'; import RiskImminentEventMap, { type EventPointFeature } from '#components/domain/RiskImminentEventMap'; +import { type HdxLayerSelection } from '#components/domain/RiskImminentEventMap/hdxLayers'; +import { type LocalUnitsSelection } from '#components/domain/RiskImminentEventMap/useLocalUnits'; import { type RiskLayerProperties } from '#components/domain/RiskImminentEventMap/utils'; import { graphql } from '#generated/gql'; import { MAX_PAGE_LIMIT } from '#utils/constants'; @@ -20,7 +22,9 @@ import { useRequest } from '#utils/restRequest'; import { JBA_IMPACT_THRESHOLD } from '../malawi/constants'; import EventDetails from './EventDetails'; import EventListItem from './EventListItem'; +import IngestionRunFilter, { type JbaIngestionRun } from './IngestionRunFilter'; import LeadTimeFilter from './LeadTimeFilter'; +import useJbaFloodExposure, { type FloodExposure } from './useJbaFloodExposure'; const JBA_FORECAST_IMPACTS_QUERY = graphql(` query JbaForecastImpacts { @@ -30,6 +34,7 @@ const JBA_FORECAST_IMPACTS_QUERY = graphql(` ) { results { id + forecastFileId forecastIssueDate forecastTargetDate leadTimeDays @@ -51,8 +56,40 @@ const JBA_FORECAST_IMPACTS_QUERY = graphql(` } `); +const JBA_FORECAST_FILE_QUERY = graphql(` + query JbaForecastFile($id: ID!) { + floodForecastFile(id: $id) { + id + leadTimeDays + tiff { + url + } + } + } +`); + +const JBA_INGESTION_RUNS_QUERY = graphql(` + query JbaIngestionRuns { + jbaIngestionRuns( + order: { runDate: DESC } + pagination: { limit: 9999 } + ) { + results { + id + runDate + forecastIssueTime + status + filesExpected + filesProcessed + completedAt + } + } + } +`); + export type JbaEvent = { id: string; + forecastFileId: string | undefined; forecastIssueDate: string; forecastTargetDate: string; leadTimeDays: number | null | undefined; @@ -65,6 +102,12 @@ export type JbaEvent = { band5P90: number | null; band5Max: number | null; ensemblesNonzeroCount: number | null; + // Populated once the admin2 REST fetch resolves (joined by adminAreaIfrcId). + districtId: number | undefined; + districtName: string | undefined; + // RP100 flood-exposed population (HDX), joined by adminAreaPcode. Static + // exposure context, not matched to this forecast's depth/lead time. + floodExposure?: FloodExposure; }; function keySelector(event: JbaEvent) { @@ -78,10 +121,13 @@ interface BaseProps { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; showLayerSelection?: boolean; - activeHdxOptionKey?: string; - onActiveHdxOptionKeyChange?: (key: string | undefined) => void; + activeHdxLayers?: HdxLayerSelection[]; + onActiveHdxLayersChange?: (next: HdxLayerSelection[]) => void; + localUnits?: LocalUnitsSelection; + onLocalUnitsChange?: (next: LocalUnitsSelection) => void; activeLeadTimeDays: number; onActiveLeadTimeDaysChange: (value: number) => void; + baseLayers?: React.ReactNode; } type Props = BaseProps & ( @@ -96,33 +142,85 @@ function Jba(props: Props) { bbox, variant, showLayerSelection, - activeHdxOptionKey, - onActiveHdxOptionKeyChange, + activeHdxLayers, + onActiveHdxLayersChange, + localUnits, + onLocalUnitsChange, activeLeadTimeDays, onActiveLeadTimeDaysChange, + baseLayers, } = props; // eslint-disable-next-line react/destructuring-assignment const iso3 = variant === 'country' ? props.iso3 : undefined; + // RP100 flood-exposed population per district (HDX), joined by pcode below. + const floodExposureByPcode = useJbaFloodExposure(isDefined(iso3)); + const [{ data, fetching: pendingImpacts }] = useQuery({ query: JBA_FORECAST_IMPACTS_QUERY, }); + const [{ data: runsData, fetching: pendingRuns }] = useQuery({ + query: JBA_INGESTION_RUNS_QUERY, + }); + const allImpactRows = data?.floodForecastImpacts?.results; - // Latest forecast issue date in the response (rows are ordered DESC). - const latestIssueDate = allImpactRows?.[0]?.forecastIssueDate; + // Most recent issue date that actually has impacts (rows are ordered DESC). + const latestImpactIssueDate = allImpactRows?.[0]?.forecastIssueDate; + + const ingestionRuns = useMemo( + () => (runsData?.jbaIngestionRuns?.results ?? []).map((run) => ({ + id: run.id, + runDate: String(run.runDate), + forecastIssueTime: isDefined(run.forecastIssueTime) + ? String(run.forecastIssueTime) : null, + status: run.status, + filesExpected: run.filesExpected ?? null, + filesProcessed: run.filesProcessed ?? null, + completedAt: isDefined(run.completedAt) ? String(run.completedAt) : null, + })), + [runsData], + ); + + const [selectedRunId, setSelectedRunId] = useState(undefined); + + // Default to the latest run that actually has impacts (the run whose runDate + // matches the most recent impact issue date), so the initial view never lands + // on a pending/failed run with no data. Any run can still be picked explicitly. + const defaultRun = useMemo(() => { + if (isDefined(latestImpactIssueDate)) { + const match = ingestionRuns.find( + (run) => run.runDate === String(latestImpactIssueDate), + ); + if (match) { + return match; + } + } + return ingestionRuns[0]; + }, [ingestionRuns, latestImpactIssueDate]); + + const activeRun = useMemo( + () => ingestionRuns.find((run) => run.id === selectedRunId) ?? defaultRun, + [ingestionRuns, selectedRunId, defaultRun], + ); + + // Fall back to the latest impact issue date so impacts still render even when + // the runs query is empty or unavailable; an explicitly selected run wins. + const activeIssueDate = activeRun?.runDate + ?? (isDefined(latestImpactIssueDate) ? String(latestImpactIssueDate) : undefined); // All rows for the latest issue date (no threshold filter). // Used to build the per-admin timeline shown in the detail chart. - const latestRows = useMemo(() => { - if (!allImpactRows || !latestIssueDate) { + // districtId / districtName are filled in once the admin2 REST call lands. + const latestRowsRaw = useMemo(() => { + if (!allImpactRows || !activeIssueDate) { return []; } const rows: JbaEvent[] = []; allImpactRows.forEach((row) => { - if (String(row.forecastIssueDate) !== String(latestIssueDate)) { + if (String(row.forecastIssueDate) !== String(activeIssueDate)) { return; } if (isNotDefined(row.band5Mean)) { @@ -137,6 +235,7 @@ function Jba(props: Props) { } rows.push({ id: row.id, + forecastFileId: row.forecastFileId ?? undefined, forecastIssueDate: String(row.forecastIssueDate), forecastTargetDate: String(row.forecastTargetDate), leadTimeDays: row.leadTimeDays, @@ -149,43 +248,19 @@ function Jba(props: Props) { band5P90: isDefined(row.band5P90) ? Number(row.band5P90) : null, band5Max: isDefined(row.band5Max) ? Number(row.band5Max) : null, ensemblesNonzeroCount: row.ensemblesNonzeroCount ?? null, + districtId: undefined, + districtName: undefined, }); }); return rows; - }, [allImpactRows, latestIssueDate]); - - // Per-admin timeline across all 10 lead times (sorted ascending). - const timelineByAdmin = useMemo(() => { - const map = new Map(); - latestRows.forEach((row) => { - const list = map.get(row.adminAreaIfrcId); - if (list) { - list.push(row); - } else { - map.set(row.adminAreaIfrcId, [row]); - } - }); - map.forEach((list) => { - list.sort((a, b) => (a.leadTimeDays ?? 0) - (b.leadTimeDays ?? 0)); - }); - return map; - }, [latestRows]); - - // Markers: rows at the selected lead time whose band5Mean >= threshold. - const events = useMemo( - () => latestRows.filter((e) => ( - e.leadTimeDays === activeLeadTimeDays - && e.band5Mean >= JBA_IMPACT_THRESHOLD - )), - [latestRows, activeLeadTimeDays], - ); + }, [allImpactRows, activeIssueDate]); const ifrcIds = useMemo( () => unique( - events.map((e) => e.adminAreaIfrcId), + latestRowsRaw.map((e) => e.adminAreaIfrcId), (id) => id, ), - [events], + [latestRowsRaw], ); const { response: adminAreasResponse } = useRequest({ @@ -207,6 +282,51 @@ function Jba(props: Props) { return map; }, [adminAreasResponse]); + // Enrich rows with admin1 district info once the admin2 REST call lands. + const latestRows = useMemo( + () => latestRowsRaw.map((row) => { + const admin = adminAreaById.get(row.adminAreaIfrcId); + return { + ...row, + districtId: admin?.district_id, + districtName: admin?.district_name, + }; + }), + [latestRowsRaw, adminAreaById], + ); + + // Per-admin timeline across all 10 lead times (sorted ascending). + const timelineByAdmin = useMemo(() => { + const map = new Map(); + latestRows.forEach((row) => { + const list = map.get(row.adminAreaIfrcId); + if (list) { + list.push(row); + } else { + map.set(row.adminAreaIfrcId, [row]); + } + }); + map.forEach((list) => { + list.sort((a, b) => (a.leadTimeDays ?? 0) - (b.leadTimeDays ?? 0)); + }); + return map; + }, [latestRows]); + + // Markers: rows at the selected lead time whose band5Mean >= threshold, + // enriched with the district's RP100 flood-exposed population. + const events = useMemo( + () => latestRows + .filter((e) => ( + e.leadTimeDays === activeLeadTimeDays + && e.band5Mean >= JBA_IMPACT_THRESHOLD + )) + .map((e) => ({ + ...e, + floodExposure: floodExposureByPcode.get(e.adminAreaPcode), + })), + [latestRows, activeLeadTimeDays, floodExposureByPcode], + ); + const pointFeatureSelector = useCallback( (event: JbaEvent): EventPointFeature | undefined => { const admin = adminAreaById.get(event.adminAreaIfrcId); @@ -280,9 +400,39 @@ function Jba(props: Props) { [events, timelineByAdmin], ); - const sidePanelHeading = useMemo(() => ( - latestIssueDate ? `${title} (issued ${String(latestIssueDate)})` : title - ), [title, latestIssueDate]); + // The selected ingestion run (issue date + status) is surfaced by the + // IngestionRunFilter in the side panel, so the heading stays clean. + const sidePanelHeading = title; + + // Resolve the forecast-file ID for the active lead time. All admin rows + // at the same (issueDate, leadTime) share the same forecastFileId, so the + // first matching row suffices. + const activeForecastFileId = useMemo(() => ( + latestRowsRaw.find((row) => row.leadTimeDays === activeLeadTimeDays)?.forecastFileId + ), [latestRowsRaw, activeLeadTimeDays]); + + // Lazy-fetch the TIFF URL for the active forecast file only. + const [{ data: forecastFileData }] = useQuery({ + query: JBA_FORECAST_FILE_QUERY, + variables: { id: activeForecastFileId ?? '' }, + pause: !activeForecastFileId, + }); + // TODO: backend returns a relative tiff.url like "/media/jba/tiff/.../lead01.tif" + // and Django doesn't add CORS headers to media file responses, so a direct + // cross-origin GET fails. Rewrite "/media/..." → "/malawi-media/..." so the + // request stays same-origin and Vite's dev proxy (see vite.config.ts) forwards + // it to the backend. Replace this once the backend serves CORS-tagged media + // (or ships absolute, CORS-correct URLs). + const activeCogUrl = useMemo(() => { + const rawUrl = forecastFileData?.floodForecastFile?.tiff?.url; + if (!rawUrl) { + return undefined; + } + if (rawUrl.startsWith('/media/')) { + return rawUrl.replace(/^\/media\//, '/malawi-media/'); + } + return rawUrl; + }, [forecastFileData]); const sidePanelFilters = ( ); + const headerActions = ( + + ); + return ( ); } diff --git a/app/src/components/domain/RiskImminentEvents/Jba/useJbaFloodExposure.ts b/app/src/components/domain/RiskImminentEvents/Jba/useJbaFloodExposure.ts new file mode 100644 index 0000000000..2f38cdef3c --- /dev/null +++ b/app/src/components/domain/RiskImminentEvents/Jba/useJbaFloodExposure.ts @@ -0,0 +1,97 @@ +import { + useEffect, + useMemo, + useState, +} from 'react'; +import Papa from 'papaparse'; +import { useQuery } from 'urql'; + +// Reuse the shared HdxDatasets query so urql de-duplicates the dataset-list +// fetch already issued by useHdxLayers (rather than firing a second operation). +import { HDX_DATASETS_QUERY } from '#components/domain/RiskImminentEventMap/useHdxLayers'; + +// HDX flood-exposure reference dataset (admin-2, keyed by ADM2_PCODE). Provides +// 1-in-100-year (RP100) flood-exposed population counts at 30 cm depth. This is +// static exposure context — NOT matched to a specific forecast's depth/lead time. +const FLOOD_EXPOSURE_DATASET = 'MWI_ADM2_flood_exposure'; + +// RP100 exposed-population sub-groups. They overlap (e.g. a female under-15 is +// counted in several) and are therefore not additive into a total. +export interface FloodExposure { + popU15: number | null; + elderly: number | null; + female: number | null; + childrenU5: number | null; +} + +interface CsvRow { + [column: string]: string | undefined; +} + +function toNumber(value: string | undefined): number | null { + if (value === undefined || value === '') { + return null; + } + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +// Returns a pcode → RP100 flood-exposure map parsed from the HDX CSV. +export default function useJbaFloodExposure(enabled: boolean): Map { + const [{ data }] = useQuery({ + query: HDX_DATASETS_QUERY, + pause: !enabled, + }); + + const hdxUrl = useMemo( + () => data?.hdxDatasets?.results?.find( + (r) => r.datasetName === FLOOD_EXPOSURE_DATASET, + )?.hdxUrl ?? undefined, + [data], + ); + + const [exposureByPcode, setExposureByPcode] = useState>( + () => new Map(), + ); + + useEffect(() => { + if (!hdxUrl) { + return undefined; + } + + let cancelled = false; + Papa.parse(hdxUrl, { + download: true, + header: true, + skipEmptyLines: true, + complete: (parseResults) => { + if (cancelled) { + return; + } + const map = new Map(); + parseResults.data.forEach((row) => { + const pcode = row.ADM2_PCODE; + if (!pcode) { + return; + } + map.set(pcode, { + popU15: toNumber(row.RP100_pop_u15_30cm), + elderly: toNumber(row.RP100_elderly_30cm), + female: toNumber(row.RP100_female_pop_30cm), + childrenU5: toNumber(row.RP100_children_u5_30cm), + }); + }); + setExposureByPcode(map); + }, + error: () => { + if (!cancelled) { + setExposureByPcode(new Map()); + } + }, + }); + + return () => { cancelled = true; }; + }, [hdxUrl]); + + return exposureByPcode; +} diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx index 2433331f83..fd836d5107 100644 --- a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx @@ -65,6 +65,7 @@ function getLayerProperties( type BaseProps = { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; + baseLayers?: React.ReactNode; } type Props = BaseProps & ({ @@ -82,6 +83,7 @@ function MeteoSwiss(props: Props) { title, bbox, variant, + baseLayers, } = props; const { @@ -226,6 +228,7 @@ function MeteoSwiss(props: Props) { activeEventExposure={exposureResponse} activeEventExposurePending={exposureResponsePending} onActiveEventChange={handleActiveEventChange} + baseLayers={baseLayers} /> ); } diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx index f7d601ca9d..9a0de927db 100644 --- a/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx @@ -34,6 +34,7 @@ function hazardTypeSelector(item: EventItem) { type BaseProps = { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; + baseLayers?: React.ReactNode; } type Props = BaseProps & ({ @@ -51,6 +52,7 @@ function Pdc(props: Props) { title, bbox, variant, + baseLayers, } = props; const [activeEventId, setActiveEventId] = useState(); @@ -259,6 +261,7 @@ function Pdc(props: Props) { activeEventExposure={exposureResponse} activeEventExposurePending={exposureResponsePending} onActiveEventChange={handleActiveEventChange} + baseLayers={baseLayers} /> ); } diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx index 7c12c68b95..27c2be6bf4 100644 --- a/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx @@ -81,6 +81,7 @@ function getLayerProperties( type BaseProps = { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; + baseLayers?: React.ReactNode; } type Props = BaseProps & ({ @@ -98,6 +99,7 @@ function WfpAdam(props: Props) { title, bbox, variant, + baseLayers, } = props; const { @@ -228,6 +230,7 @@ function WfpAdam(props: Props) { activeEventExposure={exposureResponse} activeEventExposurePending={exposureResponsePending} onActiveEventChange={handleActiveEventChange} + baseLayers={baseLayers} /> ); } diff --git a/app/src/components/domain/RiskImminentEvents/index.tsx b/app/src/components/domain/RiskImminentEvents/index.tsx index 81acd07bcf..69d4217395 100644 --- a/app/src/components/domain/RiskImminentEvents/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/index.tsx @@ -28,6 +28,12 @@ import { environment } from '#config'; import { type components } from '#generated/riskTypes'; import { hazardTypeToColorMap } from '#utils/domain/risk'; +import ActiveCountryBaseMapLayer from '../ActiveCountryBaseMapLayer'; +import { type HdxLayerSelection } from '../RiskImminentEventMap/hdxLayers'; +import { + DEFAULT_LOCAL_UNITS_OPACITY, + type LocalUnitsSelection, +} from '../RiskImminentEventMap/useLocalUnits'; import { JBA_DEFAULT_LEAD_TIME_DAYS } from './malawi/constants'; import Arc from './Arc'; import Gdacs from './Gdacs'; @@ -48,6 +54,7 @@ type BaseProps = { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; defaultSource?: ImminentEventSource; + baseLayers?: React.ReactNode; } type Props = BaseProps & ({ @@ -66,6 +73,15 @@ function RiskImminentEvents(props: Props) { ...otherProps } = props; + const additionalProps: Props = { + ...props, + // eslint-disable-next-line react/destructuring-assignment + baseLayers: props.variant === 'country' + // eslint-disable-next-line react/destructuring-assignment + ? + : undefined, + }; + const isMalawi = ( // eslint-disable-next-line react/destructuring-assignment props.variant === 'country' && props.iso3 === MALAWI_ISO3 @@ -77,16 +93,23 @@ function RiskImminentEvents(props: Props) { const [activeView, setActiveView] = useState(defaultSource); // HDX layer selection is lifted here so it persists across JBA <-> ARC. - const [activeHdxOptionKey, setActiveHdxOptionKey] = useState(); + // Multiple layers may be active at once, each with its own representation + // (choropleth/bubble) and opacity. + const [activeHdxLayers, setActiveHdxLayers] = useState([]); + const [localUnits, setLocalUnits] = useState({ + active: false, + opacity: DEFAULT_LOCAL_UNITS_OPACITY, + }); const [activeLeadTimeDays, setActiveLeadTimeDays] = useState( JBA_DEFAULT_LEAD_TIME_DAYS, ); - // Reset HDX selection when switching to a non-Malawi source. + // Reset layer selection when switching to a non-Malawi source. useEffect(() => { if (activeView !== 'jba' && activeView !== 'arc') { // eslint-disable-next-line react-hooks/set-state-in-effect - setActiveHdxOptionKey(undefined); + setActiveHdxLayers([]); + setLocalUnits({ active: false, opacity: DEFAULT_LOCAL_UNITS_OPACITY }); } }, [activeView]); @@ -326,28 +349,30 @@ function RiskImminentEvents(props: Props) { {activeView === 'wfpAdam' && ( )} {activeView === 'gdacs' && ( )} {activeView === 'meteoSwiss' && ( )} {activeView === 'jba' && ( @@ -355,10 +380,12 @@ function RiskImminentEvents(props: Props) { {activeView === 'arc' && ( )} diff --git a/app/src/components/domain/RiskImminentEvents/malawi/constants.ts b/app/src/components/domain/RiskImminentEvents/malawi/constants.ts index 5bc6ab0af6..ddaa037775 100644 --- a/app/src/components/domain/RiskImminentEvents/malawi/constants.ts +++ b/app/src/components/domain/RiskImminentEvents/malawi/constants.ts @@ -10,3 +10,7 @@ export const ARC_IMPACT_THRESHOLD = 0.5; // user-facing RadioInput; client-side filter applied against leadTimeDays. export const JBA_LEAD_TIME_DAYS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; export const JBA_DEFAULT_LEAD_TIME_DAYS = 3; + +// Disaster type ID matching DType "Flood" in the GO REST API. +// JBA and ARC are both flood-only sources by design. +export const DISASTER_FLOOD_ID = 12; diff --git a/app/src/config.ts b/app/src/config.ts index f20620ab36..569a6b83ec 100644 --- a/app/src/config.ts +++ b/app/src/config.ts @@ -7,6 +7,7 @@ const { APP_TINY_API_KEY, APP_RISK_API_ENDPOINT, APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT, + APP_MALAWI_RISK_WATCH_ADMIN_URL, APP_TRANSLATION_API_ENDPOINT, APP_SDT_URL, APP_POWER_BI_REPORT_ID_1, @@ -33,6 +34,12 @@ export const adminUrl = APP_ADMIN_URL ?? `${api}admin/`; export const mbtoken = APP_MAPBOX_ACCESS_TOKEN; export const riskApi = APP_RISK_API_ENDPOINT; export const malawiRiskWatchGraphqlApi = APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT; +// Django admin of the Malawi Risk Watch backend. Defaults to `/admin/` on the +// GraphQL endpoint's origin. In dev the GraphQL endpoint is served same-origin +// through the Vite proxy, so set APP_MALAWI_RISK_WATCH_ADMIN_URL to the real +// backend origin for the "Review event" link to resolve correctly. +export const malawiRiskWatchAdminUrl = APP_MALAWI_RISK_WATCH_ADMIN_URL + ?? `${new URL(malawiRiskWatchGraphqlApi).origin}/admin/`; export const translationApi = APP_TRANSLATION_API_ENDPOINT; export const sdtUrl = APP_SDT_URL; export const powerBiReportId1 = APP_POWER_BI_REPORT_ID_1; diff --git a/app/src/views/FieldReportForm/index.tsx b/app/src/views/FieldReportForm/index.tsx index 350e94779f..2c6151936d 100644 --- a/app/src/views/FieldReportForm/index.tsx +++ b/app/src/views/FieldReportForm/index.tsx @@ -4,10 +4,7 @@ import { useRef, useState, } from 'react'; -import { - useLocation, - useParams, -} from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { Button, Container, @@ -76,6 +73,7 @@ import ContextFields from './ContextFields'; import EarlyActionsFields from './EarlyActionsFields'; import ResponseFields from './ResponseFields'; import RiskAnalysisFields from './RiskAnalysisFields'; +import { useFieldReportFormRouteState } from './routeState'; import SituationFields from './SituationFields'; import i18n from './i18n.json'; @@ -129,15 +127,13 @@ export function Component() { const strings = useTranslation(i18n); const formContentRef = useRef(null); const currentLanguage = useCurrentLanguage(); - const { state } = useLocation(); + const { initialValue: routeInitialValue } = useFieldReportFormRouteState(); const [activeTab, setActiveTab] = useState('context'); const [eventOptions, setEventOptions] = useState([]); const [districtOptions, setDistrictOptions] = useState([]); - const status = !fieldReportId && state?.earlyWarning - ? FIELD_REPORT_STATUS_EARLY_WARNING - : FIELD_REPORT_STATUS_EVENT; + const initialValue = !fieldReportId ? routeInitialValue : undefined; const { value, @@ -150,10 +146,11 @@ export function Component() { reportSchema, { value: { - status, + status: FIELD_REPORT_STATUS_EVENT, is_covid_report: false, visibility: VISIBILITY_PUBLIC, bulletin: BULLETIN_PUBLISHED_NO, + ...initialValue, }, }, ); diff --git a/app/src/views/FieldReportForm/routeState.ts b/app/src/views/FieldReportForm/routeState.ts new file mode 100644 index 0000000000..76c9fff586 --- /dev/null +++ b/app/src/views/FieldReportForm/routeState.ts @@ -0,0 +1,18 @@ +import { useLocation } from 'react-router-dom'; + +import { type PartialFormValue } from './common'; + +// Route state accepted by FieldReportForm at the new-report path +// (`/field-reports/new`). Callers pass partial form values that get spread on +// top of the form's defaults when creating a new report. Ignored on edit. +export interface FieldReportFormRouteState { + initialValue?: PartialFormValue; +} + +export function useFieldReportFormRouteState(): FieldReportFormRouteState { + const { state } = useLocation(); + if (!state || typeof state !== 'object') { + return {}; + } + return state as FieldReportFormRouteState; +} diff --git a/docs/malawi-risk-watch/README.md b/docs/malawi-risk-watch/README.md new file mode 100644 index 0000000000..7b6f18635a --- /dev/null +++ b/docs/malawi-risk-watch/README.md @@ -0,0 +1,110 @@ +# Malawi Risk Watch — Imminent Events + +How the Malawi-specific flood early-warning experience is built inside the GO web app's +**Imminent Events** panel, the data it consumes, and the decisions/assumptions behind it. + +> **Status:** active development on branch `project/malawi-risk-watch`. Much of this is wired against +> a **staging tileset, dev-only media proxy, and placeholder thresholds** — see +> [`assumptions.md`](./assumptions.md) before relying on any figure. Read +> [`figures.md`](./figures.md) for what every number means and its unit. + +## Contents +- [`assumptions.md`](./assumptions.md) — every assumption, placeholder, magic number, and workaround, with risk + status. +- [`figures.md`](./figures.md) — glossary of every figure/metric shown, its meaning, unit, source, and caveats. +- [`adr/`](./adr/) — Architecture Decision Records (one per major decision). + +## ⚠️ The single most important open question +`band_5` (the JBA forecast quantity behind Mean/Median/P75/P90/Max) is **labelled in the UI as +"Forecast flood depth (m)"**, but the backend is self-contradictory about what it is — the docs say +"flood depth" while the dummy-data generator says "people affected". This must be confirmed with the +JBA data owners; if it is a population/impact count, the depth labelling and the `m` units are wrong by +orders of magnitude. See [`assumptions.md` → A1](./assumptions.md#a1--band_5-unit-flood-depth-vs-people-affected). + +## What this feature is +For Malawi (`iso3 === 'MWI'`) the Imminent Events panel gains two extra sources backed by the +**Malawi Risk Watch GraphQL API** (separate from the GO REST/risk APIs): + +- **JBA** — probabilistic flood-**forecast** ensemble statistics, per district, for lead times **1–10 days**. +- **ARC** — parametric **rainfall observations**, per district, for the latest observation date. + +Both are **flood-only** (`hazardTypeSelector` always returns `'FL'`) and both can seed a GO field / +early-warning report (`dtype = DISASTER_FLOOD_ID = 12`). They render through the same shared +`RiskImminentEventMap` (map + side panel) used by the global sources (GDACS/PDC/WfpAdam/MeteoSwiss), +plus Malawi-only **HDX context choropleth layers**. + +## Component map +All paths under `app/src/components/domain/`. + +| Component | Path | Role | +|---|---|---| +| Parent view | `views/CountryProfileRiskWatch/` | Renders ``; the only place that drives the Malawi case. | +| Orchestrator | `RiskImminentEvents/index.tsx` | `isMalawi` gate, source radios (JBA/ARC only for Malawi), default source `'jba'`; owns lifted state (`activeView`, `activeHdxOptionKeys`, `activeLeadTimeDays`). | +| Shared constants | `RiskImminentEvents/malawi/constants.ts` | `JBA_IMPACT_THRESHOLD`, `ARC_IMPACT_THRESHOLD`, `JBA_LEAD_TIME_DAYS`, `JBA_DEFAULT_LEAD_TIME_DAYS`, `DISASTER_FLOOD_ID` (all placeholders/TODO). | +| JBA source | `RiskImminentEvents/Jba/index.tsx` | 3 GraphQL queries + admin2 REST + exposure hook; run/lead-time selection; markers + per-admin timelines; COG url rewrite. | +| └ ingestion-run select | `Jba/IngestionRunFilter/` | `SelectInput` of runs (header), `IngestionRunInfo` popup as its `actions`. | +| └ lead-time slider | `Jba/LeadTimeFilter/` | Custom 1–10 numbered slider (design handoff "B2"), bound to `activeLeadTimeDays`. | +| └ exposure hook | `Jba/useJbaFloodExposure.ts` | Parses HDX `MWI_ADM2_flood_exposure` CSV → `Map` (RP100, 30 cm). | +| └ detail + chart | `Jba/EventDetails/` (+ `LeadTimeChart/`) | Depth figures + InfoPopup, uncertainty fan, exposed-population, ensembles count, field-report link. | +| ARC source | `RiskImminentEvents/Arc/` (+ `EventListItem/`, `EventDetails/`) | 1 GraphQL query + admin2 REST; latest-observation markers; rainfall/impact/RP/cellTrigger detail. | +| Shared map | `RiskImminentEventMap/index.tsx` | Generic map + side panel: markers, footprint, HDX choropleths, COG raster, layer dropdown, legend, event list. | +| └ HDX layers | `RiskImminentEventMap/{useHdxLayers.ts, hdxLayers.ts}` | Recipe table + grouped multi-select + CSV parse + 5-bin quantile choropleth. | +| └ COG raster | `RiskImminentEventMap/JbaCogRasterLayer/` | Client-side GeoTIFF decode → Mapbox image source. | +| GraphQL client | `utils/graphql/index.ts` | Single urql client → `malawiRiskWatchGraphqlApi`; provided app-wide in `App/index.tsx`. | +| Backend (read-only) | `malawi-risk-watch-backend/` (submodule) | Source schema + `docs/`. `apps/pipeline/models.py`, `apps/admin_areas/models.py`. | + +## Data flow + +**JBA** +1. **Fetch** — `JbaForecastImpacts` (all rows, `forecastIssueDate DESC`, `limit 9999`), `JbaIngestionRuns` + (`runDate DESC`), lazily `JbaForecastFile(id)` for the active lead time, and (via `useJbaFloodExposure`, + reusing the shared `HdxDatasets` query) the `MWI_ADM2_flood_exposure` CSV. +2. **Select run** — `activeRun` = explicitly selected run, else the run whose `runDate` matches the latest + impact issue date (fallback: newest run, then the latest impact issue date). `activeIssueDate` drives row selection. +3. **Transform** — keep impact rows where `forecastIssueDate === activeIssueDate`, `band5Mean` present, + and `adminArea.ifrcId` present (null-`ifrcId` rows dropped with a `console.warn`). `GET /api/v2/admin2/?id__in=…` + enriches each row with `centroid` (marker), `bbox` (footprint), `district_id`/`district_name`. +4. **Markers + timeline** — `timelineByAdmin` groups all 10 lead-time rows per admin; `events` = rows at + `activeLeadTimeDays` with `band5Mean >= JBA_IMPACT_THRESHOLD`, each joined to flood exposure by `adminAreaPcode`. +5. **COG** — `activeForecastFileId` → `JbaForecastFile` → `tiff.url`; rewrite leading `/media/` → `/malawi-media/` + (dev proxy). On "Show forecast raster", `JbaCogRasterLayer` decodes the COG with `geotiff` and overlays it. +6. **Render** — `RiskImminentEventMap` (source `'jba'`) draws markers, footprint, HDX choropleths, optional COG, + the side-panel list, and on expand `EventDetails` (fed `activeTimeline` for the chart). + +**ARC** — `ArcRainfallObservations` (latest `observationDate`, `impact >= ARC_IMPACT_THRESHOLD`) → admin2 REST +enrich → markers + bbox footprint. No lead-time / ingestion-run / COG concepts. + +**HDX overlay (shared, Malawi-only)** — when `showLayerSelection`, `useHdxLayers` runs `HdxDatasets`, intersects +returned dataset names with the recipe table, and on toggling a metric parses that dataset's CSV (cached by URL), +computes a 5-bin quantile choropleth (`pcode → colour`), and paints stacked admin-2 fills (`fill-opacity 0.4`, +selection order) on the `go-admin2-${iso3}-staging` vector tileset (`ADM2_PCODE` → feature `code`). Selected layer +keys persist across JBA↔ARC because they are lifted to `RiskImminentEvents`. + +## State model +- **`RiskImminentEvents`** owns `activeView`, `activeHdxOptionKeys` (reset to `[]` when leaving jba/arc), + `activeLeadTimeDays` (init `3`) — passed to both Jba and Arc. +- **`Jba`** owns `selectedRunId` and `activeTimeline` (active admin's 10-lead-time series, passed up as + `activeEventExposure`). +- **`Arc`** owns `activeIfrcId` (active admin, used by `footprintSelector`). +- **`RiskImminentEventMap`** owns map UI state: `activeEventId`, `layerOptions` (TC-only), `showRaster`/`rasterOpacity` + (JBA COG), debounced `bounds`. +- **Caches:** `useHdxLayers.csvByUrl` (parsed CSVs, never evicted), `useJbaFloodExposure.exposureByPcode`, + `JbaCogRasterLayer` decoded image per `cogUrl`. + +## External dependencies / endpoints +- **Malawi GraphQL API** — `malawiRiskWatchGraphqlApi` (`.env`: `http://localhost:3000/malawi-graphql`, Vite-proxied + to backend `/graphql/`). Codegen schema: `malawi-risk-watch-backend/schema.graphql`. Ops: `JbaForecastImpacts`, + `JbaIngestionRuns`, `JbaForecastFile`, `ArcRainfallObservations`, `HdxDatasets` (shared by `useHdxLayers` and + `useJbaFloodExposure`). +- **GO REST** — `GET /api/v2/admin2/?id__in=…` (centroid/bbox/district), and in the parent view + `/api/v1/country-imminent-counts/` (default-source resolution). +- **HDX CSVs** — downloaded **directly in the browser** from `hot.storage.heigit.org/.../mwi/MWI_ADM2_*.csv` + (not via the media proxy; relies on HEIGIT CORS). +- **Mapbox** — vector tiles `mapbox://go-ifrc.go-admin2-${iso3}-staging` (⚠ staging). +- **Backend media** — JBA COG TIFFs at `/media/jba/tiff/…` (dev: `/malawi-media/` proxy; ⚠ no prod equivalent yet). + +## Known gaps / follow-ups (see ADRs + assumptions) +- Confirm `band_5` semantics + unit (A1) — blocks correctness of the depth UI. +- Replace placeholder thresholds (A2/A3) and the `-staging` tileset (A12). +- Production media access for the COG (no `/malawi-media` proxy outside dev). +- Wire i18n for the hard-coded Malawi strings (`// FIXME: use strings`). +- HDX layer panel: per-layer opacity/representation controls and "Local units" group from the handoff are unbuilt. diff --git a/docs/malawi-risk-watch/adr/0001-malawi-sources.md b/docs/malawi-risk-watch/adr/0001-malawi-sources.md new file mode 100644 index 0000000000..645633ebb5 --- /dev/null +++ b/docs/malawi-risk-watch/adr/0001-malawi-sources.md @@ -0,0 +1,32 @@ +# ADR 0001 — Add JBA & ARC as Malawi-only imminent-event sources + +**Status:** implemented + +## Context +The global Imminent Events panel exposed four sources (GDACS, PDC, WfpAdam, MeteoSwiss). Malawi Risk Watch needed two +Malawi-specific feeds from its **own GraphQL backend**: JBA ensemble flood-depth forecasts and ARC parametric rainfall +observations. They must appear **only** for Malawi and not pollute global/region/other-country views. + +## Decision +- Extend `ImminentEventSource` to `'pdc' | 'wfpAdam' | 'gdacs' | 'meteoSwiss' | 'jba' | 'arc'`. +- Gate on `isMalawi = (variant === 'country' && iso3 === MALAWI_ISO3)` (`MALAWI_ISO3 = 'MWI'`); JBA/ARC source radios + render only when `isMalawi`. +- Default source `defaultSource ?? (isMalawi ? 'jba' : 'gdacs')`. +- Both sources are **flood-only** (`hazardTypeSelector` → `'FL'`); field/early-warning reports use + `DISASTER_FLOOD_ID = 12`. +- Data via the Malawi urql client (`floodForecastImpacts` / `arcRainfallObservations`); admin areas joined through + GO REST `/api/v2/admin2/` by `adminArea.ifrcId`. + +## Alternatives +- Make JBA/ARC available globally or behind a generic country flag instead of a hard-coded `'MWI'` check. +- Reuse the existing `environment !== 'production'` gate (used by WfpAdam/MeteoSwiss) instead of an `iso3` gate. +- i18n entries for the `JBA`/`ARC` labels (left hard-coded with `// FIXME: use strings`). + +## Consequences +- Malawi country view defaults to the JBA tab. The `'MWI'` literal is implicitly duplicated (e.g. `useCountry({ iso3: 'MWI' })`). +- Adding a second risk-watch country would require generalising the `isMalawi` gate. +- JBA/ARC depend on a separate backend origin reached via a dev proxy (see [ADR 0006](./0006-workarounds.md)). +- Source labels ship untranslated. + +## Files +`RiskImminentEvents/index.tsx` (42–44, 80–86, 308–375), `RiskImminentEvents/malawi/constants.ts`, `Jba/index.tsx`, `Arc/index.tsx`. diff --git a/docs/malawi-risk-watch/adr/0002-hdx-layer-multiselect.md b/docs/malawi-risk-watch/adr/0002-hdx-layer-multiselect.md new file mode 100644 index 0000000000..41db6f772e --- /dev/null +++ b/docs/malawi-risk-watch/adr/0002-hdx-layer-multiselect.md @@ -0,0 +1,55 @@ +# ADR 0002 — HDX context layers: grouped multi-select Switch → stacked choropleths + +**Status:** implemented — see the **Update** below (the searchable/collapsible panel with per-layer +opacity + Choropleth/Bubble representation has since shipped; only the point-layer groups remain deferred) + +## Context +Malawi needs HDX reference datasets (flood exposure, vulnerability, facilities, access, demographics, rural +population) shown as background context over the JBA/ARC maps. The earlier design allowed **one** HDX layer at a time +(singular `activeHdxOptionKey` + `RadioInput`). Users needed to compare several context metrics together. + +## Decision +- Convert to multi-select: `activeHdxOptionKeys: string[]`, lifted into `RiskImminentEvents` so selection persists + across JBA↔ARC and resets to `[]` when leaving jba/arc. +- The selector is a `DropdownMenu` ("Layers (n)") containing one `Container` per HDX **dataset group**, each holding a + `Switch` per metric. Recipes/grouping live in `hdxLayers.ts`; CSV fetch/parse, 5-bin quantile binning, and + `pcode → colour` resolution live in `useHdxLayers.ts`. +- Each active layer renders as a separate admin-2 fill `MapLayer` at `fill-opacity: 0.4`, stacked in selection order + over `go-admin2-${iso3}-staging`, with a shared semi-transparent outline. +- One `StepGradientBar` legend block per active layer; bin labels use `formatNumber({ compact: true })` for counts or + `n%` for percent metrics. A degenerate uniform metric (e.g. all-zero `hospitals_count`) collapses to a single swatch. + +## Alternatives +- The kept-but-superseded single-select `RadioInput`. +- The richer per-layer controls in `design_handoff_layers_panel` (Direction 1): per-layer representation toggle + (Choropleth | Bubble), per-layer **opacity slider** (default 80%), searchable/collapsible panel, layer reordering, + and a "Local units" point-layer group — **not built**. Opacity is hard-coded to `0.4` (`// FIXME: expose…`). + +## Consequences +- Multiple layers can stack, but at fixed 0.4 opacity overlapping fills can read ambiguously and the legend stops + matching on-map colour. No user opacity/representation control yet. +- Parsed CSVs cached by URL, never evicted (bounded by the recipe table). Unknown HDX datasets silently skipped. +- Quantile bins are dataset-relative (recomputed client-side per metric). Bubble representation and "Local units" + remain unbuilt → this ADR is **partial** (see the active task to update the panel to the handoff). + +## Files +`RiskImminentEventMap/index.tsx`, `hdxLayers.ts`, `useHdxLayers.ts`, +`RiskImminentEvents/index.tsx`, `design_handoff_layers_panel/README.md`. + +## Update — panel redesign shipped (design_handoff_layers_panel "Tidy" / D1) +The deferred per-layer controls have since been built: +- **New `LayersPanel`** (`RiskImminentEventMap/LayersPanel/`): searchable (`TextInput`), collapsible + groups with red count badges, an "N active · Clear all" bar. Decomposed into `LayerGroup` + + `LayerControls` + a custom `OpacitySlider` (GO ships no slider) reusing `DropdownMenu`/`Switch`/`SegmentInput`. +- **State model** changed from `activeHdxOptionKeys: string[]` to + **`HdxLayerSelection[]`** (`{ key, representation: 'choropleth' | 'bubble', opacity: 0–100 }`). +- **Per-layer opacity** now drives `fill-opacity` (the hard-coded `0.4` is gone — see assumption A5). +- **Bubble representation** renders live: a graduated-circle layer per bubble layer at admin-2 + centroids (from `/api/v2/admin2/?admin1__country__iso3=…`, joined by `code`=pcode), radius scaled by + value; the legend is representation-aware (gradient bar vs graduated-size sample). `useHdxLayers` now + exposes `pcodeToValue`/`valueRange`/`rampColor`. +- **Local Units point layer** has since been added — a single toggleable red-marker layer (own opacity) fed by + `useLocalUnits` (`/api/v2/public-local-units/?country__iso3`, the same source as the NS `LocalUnitsMap`), with a + red-dot legend item. Per-type sub-toggles and the type-specific icons from `LocalUnitsMap`, plus the + **Facilities** point group, remain deferred. +- `activeKeys` is keyed on a join-string so opacity/representation tweaks don't re-resolve layers. diff --git a/docs/malawi-risk-watch/adr/0003-ingestion-run-selector.md b/docs/malawi-risk-watch/adr/0003-ingestion-run-selector.md new file mode 100644 index 0000000000..2105651edf --- /dev/null +++ b/docs/malawi-risk-watch/adr/0003-ingestion-run-selector.md @@ -0,0 +1,35 @@ +# ADR 0003 — JBA ingestion-run selector in the header (run-info popup, data-aware default) + +**Status:** implemented + +## Context +JBA delivers one ingestion run per day; each run's `forecastIssueDate` keys that day's impacts. Users need to switch +runs, and the initial view must not land on a pending/failed run with no impacts. The previous JBA heading appended +the issue date as `(issued yyyy-mm-dd)`. + +## Decision +- Add `IngestionRunFilter`, a compact label-less `SelectInput` rendered in the side-panel `Container`'s + **`headerActions`** (passed through a new `RiskImminentEventMap` `headerActions` prop). Options are `jbaIngestionRuns` + ordered `runDate DESC`; the option label is the run's `runDate`. +- An `IngestionRunInfo` `InfoPopup` (run date, status, files processed `n/m`, forecast issued, completed) is wired as + the `SelectInput`'s **`actions`** slot (`@ifrc-go/ui` has no literal `after`). +- **Default run** = the run whose `runDate` matches the most recent **impact** issue date, else `ingestionRuns[0]`. + `activeIssueDate` falls back to the latest impact issue date when the runs query is empty/unavailable — so impacts + still render independent of the runs query. +- `runDate` is treated as equal to `forecastIssueDate` (string-compared). The `(issued …)` heading suffix is removed. +- The side-panel `empty` gate was relaxed to `events.length === 0` so a no-impact run shows the empty message while + the header/filters stay visible (only JBA passes `sidePanelFilters`). + +## Alternatives +- Keep the issue date in the heading instead of a header select + popup. +- Default to the newest run unconditionally (`ingestionRuns[0]`) — rejected to avoid empty default views. +- The select is `nonClearable`, so there is always an active run. + +## Consequences +- Run metadata is discoverable via the popup; the heading stays clean. +- The `runDate === forecastIssueDate` mapping is assumed (see [assumptions A12](../assumptions.md#-a12--rundate--forecastissuedate-ingestion-run--impacts-join)); the `activeIssueDate` fallback limits the blast radius. +- **Inconsistency:** ARC still appends `(observed )` to its heading, so JBA and ARC headings now differ. +- `IngestionRunInfo` labels are hard-coded (`// FIXME: use strings`). + +## Files +`Jba/IngestionRunFilter/index.tsx`, `Jba/index.tsx` (run state, default, fallback, heading), `RiskImminentEventMap/index.tsx` (`headerActions` prop; `empty` gate). diff --git a/docs/malawi-risk-watch/adr/0004-lead-time-slider.md b/docs/malawi-risk-watch/adr/0004-lead-time-slider.md new file mode 100644 index 0000000000..dac7a5a0f2 --- /dev/null +++ b/docs/malawi-risk-watch/adr/0004-lead-time-slider.md @@ -0,0 +1,37 @@ +# ADR 0004 — Forecast lead-time numbered slider (replaces the radio group) + +**Status:** implemented + +## Context +JBA produces 10 TIFFs/day (one per lead time, day 1–10). The earlier control was a `RadioInput` group ("1d 2d … 10d"). +The design handoff (`design_handoff_lead_time_selector`, direction **B2 — Numbered Slider**) called for a single +horizontal slider with a numbered 1–10 scale. GO ships **no** slider primitive. + +## Decision +Build `LeadTimeFilter` as a bespoke, dependency-free slider: +- A track (`role="slider"`) with a red fill, a 20 px white handle (red border), and a row of clickable numbered + buttons 1–10. Range/`default` from `JBA_LEAD_TIME_DAYS = [1..10]` / `JBA_DEFAULT_LEAD_TIME_DAYS = 3`; state lifted to + `RiskImminentEvents`. +- Interaction: pointer drag with `setPointerCapture` (+ `onPointerCancel` reset), keyboard (Arrow/Home/End, clamped), + and direct number-button clicks. Touch hit-area extended beyond the 6 px track. +- A11y: `aria-valuemin/max/now/valuetext` on the track; `aria-pressed` per number button; `:focus-visible` rings. +- **All design tokens mapped to GO CSS variables** (`--go-ui-color-primary-red`, `--go-ui-color-gray-30`, + `--go-ui-box-shadow-sm`, `--go-ui-font-*`, `--go-ui-border-radius-full`, focus ring via `color-mix`). Label via + `InputLabel`. Only raw geometry (20 px / 2.5 px / 6 px) is literal, per the handoff. +- Selecting `leadTimeDays` filters the markers and selects the COG TIFF to overlay. + +## Alternatives +- The prior `RadioInput` group (replaced). +- Other slider directions on the prototype canvas (bubble, notched, handle-pill, bar scrubber, dropdown, stepper, + timeline); **B2** ("refined & wired to data") was chosen. The icon-marker handle variant was rejected — B2 uses no icons. + +## Consequences +- A reusable accessible slider now exists without adding a GO primitive. +- Changing lead time re-renders impacts and lazily fetches that lead day's COG; there is **no explicit + loading/empty state** for a not-yet-available lead time (a follow-up the handoff flagged). +- Label/day strings are hard-coded (`// FIXME: use strings`). +- GO's `formatNumber` ignores its `unit` option, so the unit is shown in headings, not per value (relevant to ADR 0005). + +## Files +`Jba/LeadTimeFilter/index.tsx`, `Jba/LeadTimeFilter/styles.module.css`, `malawi/constants.ts` (9–12), +`RiskImminentEvents/index.tsx` (93–95), `design_handoff_lead_time_selector/README.md`. diff --git a/docs/malawi-risk-watch/adr/0005-impact-figures-fan-population.md b/docs/malawi-risk-watch/adr/0005-impact-figures-fan-population.md new file mode 100644 index 0000000000..d0cb5435d1 --- /dev/null +++ b/docs/malawi-risk-watch/adr/0005-impact-figures-fan-population.md @@ -0,0 +1,38 @@ +# ADR 0005 — JBA impact figures, ensemble-percentile uncertainty fan, exposed-population + +**Status:** implemented + +## Context +Each JBA impact row carries `band_5` ensemble statistics (mean, median, p75, p90, max) across 51 members, plus +`ensembles_nonzero_count`; raw per-member values are not stored. The detail view needed to communicate the central +estimate, the spread/uncertainty across lead times, and a sense of who is exposed — **without overstating precision**. + +## Decision +- **Figures + units:** group Mean/Median/P75/P90/Max under a **"Forecast flood depth (m)"** `Container` with an + `InfoPopup` explaining they are ensemble percentiles across the 51 members (P90 ≫ mean ⇒ tail risk). The unit is in + the heading (GO `formatNumber` ignores its `unit` option). ⚠ The "depth/(m)" labelling is **unconfirmed** — see + [assumptions A1](../assumptions.md#a1--band_5-unit-flood-depth-vs-people-affected). +- **Uncertainty fan:** `LeadTimeChart` draws the per-admin trajectory across all 10 lead times — the **mean line**, a + **threshold** line, an outer **median→Max envelope**, and an inner **median→P90** band, with the y-domain scaled to + the full spread so the fan doesn't clip. Point tooltip lists Mean/Median/P90/Max. +- **Exposed population (per district):** `useJbaFloodExposure` joins HDX `MWI_ADM2_flood_exposure` (RP100, 30 cm) by + `ADM2_PCODE` and renders Under-15, Elderly (65+), Female, Under-5 `KeyFigure`s (compact). An `InfoPopup` states the + figures are **static RP100 context, not matched to the forecast, and overlap (non-additive)**. + +## Alternatives +- Show only the mean (the prior plain trajectory line). +- A different band (e.g. P25→P75) — only median, P90, and Max are available as band edges. +- Attempt to attribute exposure to the specific forecast depth/lead time, or sum a "total people affected" — rejected: + the CSV has **no total column** and the subgroups overlap, so summing would be wrong. Labelled approximate instead. +- An ensemble-likelihood gauge (`nonzeroCount/51` as probability) was proposed but **not** selected. + +## Consequences +- The chart needs ≥2 timeline points to draw bands (`buildBandPath` returns undefined below 2). The fan bridges + straight across any interior lead-day with a null percentile (cosmetic; mean line breaks there). +- Exposure subgroups must never be summed — enforced by labelling, not by data. +- `Under-5` reads `RP100_children_u5_30cm`, a CSV column **not** in the `hdxLayers` recipe (detail-only, may be null). +- Labels/tooltips hard-coded (`// FIXME: use strings`). + +## Files +`Jba/EventDetails/index.tsx`, `Jba/EventDetails/LeadTimeChart/index.tsx`, `Jba/useJbaFloodExposure.ts`, +backend `apps/pipeline/models.py` (120–147), `docs/data-pipeline.md` (80–83). diff --git a/docs/malawi-risk-watch/adr/0006-workarounds.md b/docs/malawi-risk-watch/adr/0006-workarounds.md new file mode 100644 index 0000000000..3cde6578c2 --- /dev/null +++ b/docs/malawi-risk-watch/adr/0006-workarounds.md @@ -0,0 +1,34 @@ +# ADR 0006 — Workarounds: media proxy (CORS), staging tileset, unwired i18n + +**Status:** partial (all are deliberate temporary measures awaiting proper fixes) + +## Context +The Malawi backend returns relative TIFF URLs like `/media/jba/tiff/.../lead01.tif` and Django does **not** attach +CORS headers to media responses, so a direct cross-origin GET from the GO dev server to the backend fails. The feature +was also built against a **staging** admin-2 vector tileset, and the Malawi UI was developed **ahead of** i18n wiring. + +## Decision +- **COG / media (CORS):** rewrite `/media/...` → `/malawi-media/...` so the request stays same-origin, with Vite's dev + proxy forwarding `/malawi-media` → backend `/media` (sibling to a `/malawi-graphql` → `/graphql` proxy). The COG is + streamed into a Mapbox raster source by `JbaCogRasterLayer` when the raster toggle is on. +- **Tileset:** choropleth fill/outline bind to `mapbox://go-ifrc.go-admin2-${iso3}-staging` / source-layer + `go-admin2-${iso3}-staging`, both flagged `// FIXME: update layer name`. +- **i18n:** many Malawi strings (source labels, "Layers", lead-time label, all EventDetails/IngestionRunInfo labels, + chart tooltips, "Create early warning report") are hard-coded with `// FIXME: use strings`. + +## Alternatives (the proper fixes, deferred) +- **Media:** have the backend serve CORS-tagged media or ship absolute, CORS-correct URLs, removing the rewrite. +- **Tileset:** point at a production (non-staging) admin-2 tileset and verify the `ADM2_PCODE == code` join for MWI. +- **i18n:** add the strings to `i18n.json` and wire `useTranslation`, as the rest of the module already does + (`RiskImminentEvents/i18n.json`, per-source i18n files). + +## Consequences +- The `/malawi-media` rewrite **only works in dev**. Production needs an equivalent same-origin path or backend CORS, + or the COG overlay fails cross-origin (caught + logged). +- The hard-coded `-staging` tileset breaks/shows stale geometry if removed or renamed. +- Untranslated strings ship English-only. Because the i18n-usage eslint rule scans only the co-located `index.tsx` for + `strings.*`, these FIXMEs are currently invisible to it (no keys exist yet). + +## Files +`Jba/index.tsx` (COG url rewrite), `app/vite.config.ts` (proxies), `RiskImminentEventMap/index.tsx` (tileset), +`JbaCogRasterLayer/index.tsx`, and the various components with `// FIXME: use strings`. diff --git a/docs/malawi-risk-watch/adr/0007-review-event-admin-link.md b/docs/malawi-risk-watch/adr/0007-review-event-admin-link.md new file mode 100644 index 0000000000..618bf7c01f --- /dev/null +++ b/docs/malawi-risk-watch/adr/0007-review-event-admin-link.md @@ -0,0 +1,42 @@ +# ADR 0007 — "Review event" link to the Malawi backend Django admin + +**Status:** implemented + +## Context +JBA event details already expose a **Create early warning report** link. Operators also need a fast path to the +underlying record in the Malawi Risk Watch backend's Django **admin** (to inspect/curate the ingested forecast). The +frontend knows the backend only through `APP_MALAWI_RISK_WATCH_GRAPHQL_ENDPOINT`, and in dev that endpoint is the +frontend origin (`http://localhost:3000/malawi-graphql`) reverse-proxied by Vite to the real backend — so the real +backend origin is not directly known to the client. + +## Decision +- Add a sibling **`Review event`** `Link` (external, `withLinkIcon`, `outline`/`primary`) beside + *Create early warning report* in `Jba/EventDetails`. Both links are wrapped in a row `ListView` and gated behind the + existing `canCreateReport = isAuthenticated && !isGuestUser`. +- It opens the admin **index** (`…/admin/`), not a per-event change page. The GraphQL `id` on a flood-forecast impact + has no known Django `app/model/pk` path, and the request was literally "backend domain/admin", so a reliable deep + link is out of scope (see Consequences). +- New config export `malawiRiskWatchAdminUrl` (`config.ts`): + `APP_MALAWI_RISK_WATCH_ADMIN_URL ?? \`${new URL(malawiRiskWatchGraphqlApi).origin}/admin/\``. New **optional**, + url-validated env var registered in `env.ts`; a commented example added to `.env`. This mirrors the existing + `adminUrl = APP_ADMIN_URL ?? \`${api}admin/\`` pattern for the GO backend. + +## Alternatives +- Deep-link to `…/admin////change/` — rejected: the GraphQL id → Django admin route is unknown. +- Reuse the GraphQL endpoint origin only (no override env var) — rejected: in dev the endpoint is the *frontend* + origin (proxied), so the derived `…/admin/` would be a dead link without an explicit override. +- Add a `/malawi-admin` Vite proxy like `/malawi-graphql` — unnecessary: the admin is a browser navigation (new tab), + not a CORS-constrained `fetch`, so it only needs the real origin, not a same-origin proxy. + +## Consequences +- **Default dev is a dead link:** with the default proxied endpoint, the fallback resolves to + `http://localhost:3000/admin/` (the frontend). Set `APP_MALAWI_RISK_WATCH_ADMIN_URL=http://localhost:8060/admin/` + (or the deployed backend's `/admin/`) for it to work. In prod the GraphQL endpoint is the real backend origin, so + the fallback is correct. +- Link label is hard-coded (`// FIXME: use strings`). +- Index-only target means "Review event" lands operators on the admin home, not the specific record; revisit if the + backend later exposes a stable per-impact admin path. + +## Files +`Jba/EventDetails/index.tsx` (the link + row), `config.ts` (`malawiRiskWatchAdminUrl`), `env.ts` +(`APP_MALAWI_RISK_WATCH_ADMIN_URL`), `.env` (commented example). diff --git a/docs/malawi-risk-watch/adr/README.md b/docs/malawi-risk-watch/adr/README.md new file mode 100644 index 0000000000..206515857e --- /dev/null +++ b/docs/malawi-risk-watch/adr/README.md @@ -0,0 +1,17 @@ +# Architecture Decision Records — Malawi Risk Watch + +Each ADR captures one decision: the problem (context), what was chosen, alternatives considered, and consequences. +Status reflects the code on branch `project/malawi-risk-watch`. + +| # | Decision | Status | +|---|---|---| +| [0001](./0001-malawi-sources.md) | Add JBA & ARC as Malawi-only imminent-event sources | implemented | +| [0002](./0002-hdx-layer-multiselect.md) | HDX context layers: grouped multi-select Switch → stacked choropleths | partial | +| [0003](./0003-ingestion-run-selector.md) | JBA ingestion-run selector in the header (run-info popup, data-aware default) | implemented | +| [0004](./0004-lead-time-slider.md) | Forecast lead-time numbered slider (replaces the radio group) | implemented | +| [0005](./0005-impact-figures-fan-population.md) | JBA impact figures, ensemble-percentile uncertainty fan, exposed-population | implemented | +| [0006](./0006-workarounds.md) | Workarounds: media proxy (CORS), staging tileset, unwired i18n | partial | + +> ADRs 0002–0005 correspond to the layer panel, ingestion-run selector, lead-time slider, and impact/charts work +> done iteratively in this branch. ADR 0006 collects the deferred infra fixes. All figures/units are in +> [`../figures.md`](../figures.md); all assumptions/risks in [`../assumptions.md`](../assumptions.md). diff --git a/docs/malawi-risk-watch/assumptions.md b/docs/malawi-risk-watch/assumptions.md new file mode 100644 index 0000000000..754519cd59 --- /dev/null +++ b/docs/malawi-risk-watch/assumptions.md @@ -0,0 +1,139 @@ +# Malawi Risk Watch — Assumptions, Placeholders & Open Questions + +Everything the implementation **assumes**, **hard-codes as a placeholder**, or **works around**, with where it +lives, the basis (`verified` / `inferred` / `placeholder` / `workaround`), and the risk if it's wrong. Verified +against the code on branch `project/malawi-risk-watch`. Paths under `app/src/components/domain/` unless noted. + +**Legend:** 🔴 correctness-critical · 🟠 placeholder to replace before prod · 🟡 dev-only workaround · 🟢 verified, low risk + +--- + +## 🔴 A1 — `band_5` unit: flood depth vs people affected +**`inferred`.** The UI presents `band5Mean/Median/P75/P90/Max` as **flood depth in metres**: the EventDetails +heading is "Forecast flood depth (m)", the InfoPopup says "flood depth (in metres) across the 51 ensemble members", +and `LeadTimeChart` tooltips suffix values with ` m`. The backend **contradicts itself**: `docs/data-pipeline.md` +and `docs/project-overview.md` call `band_5` "flood depth", **but** `create_dummy_data.py:58-68` comments the +samples are *"people affected; always whole numbers in 100s/1000s"* (values 100…18000). The **real data** observed +in the running app (≈0.4–4.2) matches *depth in metres*, so the dummy generator appears to be the outlier — but this +is unconfirmed. +- *Where:* `Jba/EventDetails/index.tsx` (heading/InfoPopup), `Jba/EventDetails/LeadTimeChart/index.tsx` (tooltip `m`); backend `create_dummy_data.py:58-68`. +- *Risk:* If `band_5` is a population/impact count, **every JBA figure, the "(m)" labelling, the fan-chart axis, and the threshold semantics are wrong by orders of magnitude.** **Action: confirm with JBA/MRCS data owners.** + +## 🟠 A2 — `JBA_IMPACT_THRESHOLD = 0.5` is a placeholder +**`placeholder`.** Gates which districts get a marker (`band5Mean >= 0.5`) and draws the dashed threshold line. +The constants file header says these are TODO placeholders "intentionally loose for demos, pending MRCS/JBA confirmation". +- *Where:* `malawi/constants.ts:6`; used `Jba/index.tsx` (events filter), `LeadTimeChart/index.tsx` (threshold line). +- *Risk:* Arbitrary cutoff. If `band_5` is population, `0.5` admits virtually every nonzero district (no real filtering); if depth, `0.5 m` is meaningful but unvalidated. The on-map event set is **not** a validated trigger. + +## 🟠 A3 — `ARC_IMPACT_THRESHOLD = 0.5` is a placeholder, not the real trigger +**`placeholder`.** Filters ARC observations (`impact >= 0.5`) and is shown as "Applied threshold". The **real** +backend trigger is `cell_trigger` on `rainfall >= 25.4 mm`; dummy `impact = round(rainfall*42)` lands in the hundreds. +- *Where:* `malawi/constants.ts:7`; `Arc/index.tsx`, `Arc/EventDetails/index.tsx`; backend `create_dummy_data.py:224,231`. +- *Risk:* `impact >= 0.5` admits essentially every nonzero observation, so the ARC list is effectively unfiltered and does **not** match the parametric trigger. Users may read it as the real trigger condition. + +## 🟠 A4 — Choropleth tileset `go-admin2-${iso3}-staging` +**`placeholder`.** HDX choropleth fill/outline bind to Mapbox `mapbox://go-ifrc.go-admin2-${iso3}-staging` and +source-layer `go-admin2-${iso3}-staging`, joining HDX `ADM2_PCODE` → tileset feature `code`. Two `// FIXME: update +layer name` comments flag the `-staging` tileset as placeholder. +- *Where:* `RiskImminentEventMap/index.tsx` (fill/outline/source), `hdxLayers.ts`. +- *Risk:* Choropleths render blank wherever the staging tileset is absent; the `ADM2_PCODE == code` join is unverified for MWI. + +## 🟢 A5 — Per-layer opacity control (was: hard-coded `0.4`) — RESOLVED +**Resolved.** Each active layer now carries its own `opacity` (0–100, default 80) set via the `OpacitySlider` +in the layers panel; choropleth `fill-opacity` and bubble `circle-opacity`/`circle-stroke-opacity` read +`selection.opacity / 100`. The hard-coded `0.4` is gone (see [ADR 0002 → Update](./adr/0002-hdx-layer-multiselect.md)). +- *Where:* `RiskImminentEventMap/index.tsx`, `RiskImminentEventMap/LayersPanel/`. +- *Residual:* the outline still uses a fixed `line-opacity 0.3`; overlapping stacked layers can still blend. + +## 🟠 A6 — RP100 is the hard-coded return period for exposure +**`inferred`.** The HDX `flood_exposure` dataset has RP **10/50/100/500**-year columns (30 cm). The frontend hard-codes +only the **RP100** columns, both in `useJbaFloodExposure` and the `hdxLayers` recipe ("Flood exposure (RP100)"). +- *Where:* `Jba/useJbaFloodExposure.ts`, `hdxLayers.ts`. +- *Risk:* RP100 is an unexplained default; 10/50/500 are never surfaced. If RP100 column names change, exposure silently becomes empty (`toNumber` → null). + +## 🟡 A7 — `/malawi-media` dev proxy for the JBA COG (CORS) +**`workaround`.** The backend returns relative `/media/jba/tiff/…` URLs with no CORS headers, so the frontend rewrites +`/media/` → `/malawi-media/` and relies on **Vite's dev proxy** to forward it same-origin. `JbaCogRasterLayer` fetches +via `geotiff.fromUrl`. +- *Where:* `Jba/index.tsx` (rewrite), `app/vite.config.ts` (proxy), `JbaCogRasterLayer/index.tsx`. +- *Risk:* **Dev-only.** Production has no `/malawi-media` proxy, so the COG overlay silently fails to decode (caught + warned) unless the backend serves CORS-correct/absolute media URLs. + +## 🟡 A8 — Many Malawi strings are hard-coded (i18n not wired) +**`workaround`.** Source labels `JBA`/`ARC`, `Layers`, the lead-time label, all EventDetails/IngestionRunInfo labels, +chart tooltips, and "Create early warning report" are hard-coded with `// FIXME: use strings`. +- *Where:* `RiskImminentEvents/index.tsx`, `Jba/*`, `RiskImminentEventMap/index.tsx`. +- *Risk:* English-only; not picked up by translation tooling. Per the team's note, the i18n-usage eslint rule scans only the co-located `index.tsx` for `strings.*`, so these FIXMEs are invisible to it because no keys exist yet. + +## 🟡 A9 — HDX CSVs fetched client-side directly from HEIGIT storage +**`inferred`.** `useHdxLayers` and `useJbaFloodExposure` download HDX CSVs in the browser via `Papa.parse(hdxUrl, +{download:true})`; `hdxUrl` points at `hot.storage.heigit.org/.../mwi/MWI_ADM2_*.csv` — **not** routed through the +`/malawi-media` proxy. +- *Risk:* Works only if HEIGIT serves permissive CORS. If `hdx_url` ever pointed at backend-stored media, it would hit the same CORS issue the COG proxy solves. (Backend loader docstring calls the field `file_blob_url` but the model field is `hdx_url` — stale comment.) + +## 🟡 A10 — Queries fetch up to 9999 rows; "latest" = row[0] +**`inferred`.** `JbaForecastImpacts`, `JbaIngestionRuns`, `ArcRainfallObservations`, `HdxDatasets` all request +`pagination {limit: 9999}` and do **all** date/lead-time/threshold filtering client-side, assuming the latest +issue/observation date is `results[0]` (ordered DESC). +- *Risk:* `9999` is a magic upper bound (~320 rows/run/day could approach it over time). If row count exceeds it, "latest" detection and timelines silently truncate. + +## 🟠 A11 — `DISASTER_FLOOD_ID = 12` (GO DType "Flood") +**`inferred`.** Used as `dtype` when seeding a GO field/early-warning report from a JBA/ARC event. Comment claims it +matches the GO REST DType "Flood"; both sources are treated flood-only. +- *Where:* `malawi/constants.ts:14-16`; `Jba/EventDetails`, `Arc/EventDetails`. +- *Risk:* Not verified against the live GO disaster-type table; a wrong id mislabels prefilled reports. + +## 🟡 A12 — `runDate == forecastIssueDate` (ingestion-run ↔ impacts join) +**`inferred`.** The active run is matched to impacts by string-comparing `run.runDate === impact.forecastIssueDate` +(no FK exists). Dummy data sets `run_date = issue_date`, so it holds in dev. +- *Where:* `Jba/index.tsx`; backend `create_dummy_data.py:159-197`. +- *Risk:* If real ingestion runs on a different calendar date than the forecast issue date, the default run shows no data and rows may map to the wrong run. Mitigated by the `activeIssueDate` fallback to the latest impact issue date. + +## 🟢 A13 — RP100 flood-exposure is static context, NOT forecast-matched +**`verified`.** `useJbaFloodExposure` attaches RP100/30 cm exposed population (under-15, elderly, female, under-5) to +JBA events. The EventDetails InfoPopup states explicitly it is static context, **not** matched to the forecast's +depth/lead time, and the subgroups **overlap (non-additive)**. +- *Risk:* Users may read it as the forecast's predicted impact. RP100/30 cm is one fixed scenario; pairing it with an arbitrary lead time/depth can over/under-state exposure. Enforced by labelling only, not by data. + +## 🟢 A14 — `leadTimeDays` is a derived (resolver) field, not a DB column +**`verified`.** Backend computes it as `forecast_target_date − forecast_issue_date`; there is no column/order field. +- *Risk:* Cannot filter/order on it server-side, hence the client fetches up to 9999 rows and filters in memory; assumes target−issue is always a whole number of days. + +## 🟢 A15 — "51 ensemble members" is hard-coded in the UI +**`verified`.** EventDetails shows "X of 51"; the backend stores only `ensembles_nonzero_count` (raw 51 not retained). +Consistent with backend docs and dummy `randint(0,51)`. +- *Risk:* If JBA changes ensemble size, the `/51` denominator becomes silently wrong. + +## 🟢 A16 — JBA lead times `1..10`, default `3` +**`verified`.** `JBA_LEAD_TIME_DAYS = [1..10]`, `JBA_DEFAULT_LEAD_TIME_DAYS = 3`; 10 lead days confirmed by backend docs. +- *Risk:* The default of 3 is an unexplained UX choice; if a run lacks lead day 3, the initial view shows no markers until the slider moves. + +## 🟢 A17 — `pcode` join (forecast adminArea ↔ HDX `ADM2_PCODE`) +**`verified`.** Exposure joined by `adminAreaPcode` against CSV `ADM2_PCODE`; `AdminArea.pcode` (e.g. `MW101`) is the +documented HDX/ARC join key. +- *Risk:* Relies on exact string equality; any leading-zero/format mismatch yields silently missing exposure (absent section). + +## 🟢 A18 — `ifrcId` join to GO `/api/v2/admin2/` +**`verified`.** Both sources drop rows with null `adminArea.ifrcId` (warn), then fetch GO admin2 by `id__in=ifrcIds` +for `centroid`/`bbox`/`district_*`. +- *Risk:* Admin areas with null `ifrc_id` (backend says HDX levels 3–4) are silently dropped from the map; marker placement depends on GO returning a Point centroid. + +## 🟢 A19 — COG raster normalisation is per-image and relative +**`inferred`.** `JbaCogRasterLayer` normalises each TIFF's band-0 nonzero pixels to its **own** min/max, maps to a +light-blue→red ramp (`alpha = 110 + 145·t`), `v<=0` transparent, ~512² overview, 4× upscale, nearest resampling. +- *Risk:* Colours are **not** comparable across lead times/runs (relative, not absolute), and there is **no raster legend** or physical-value readout. Alpha floor and upscale are eyeballed magic numbers. + +## 🟢 A20 — HDX choropleth binning is 5-bin quantile, client-side; `percent` assumes 0–100 +**`inferred`.** `useHdxLayers` computes 5-bin quantile breakpoints per metric (with a uniform-metric collapse to a +single swatch). `'percent'`-format metrics assume the source is already 0–100. +- *Risk:* Bins are dataset-relative, so legends differ per metric/area set; if a `*_pct` column is actually 0–1, labels ("0%") and colours are wrong. + +--- + +## Cross-cutting notes +- **`Under-5` exposure** uses CSV column `RP100_children_u5_30cm`, which exists in the live CSV but is **not** in the + `hdxLayers.ts` flood-exposure recipe — so it's surfaced in the detail panel but not as a toggleable layer. +- **Heading inconsistency:** JBA dropped its `(issued …)` heading suffix in favour of the run selector, but ARC still + appends `(observed )`. +- **Chart needs ≥2 timeline points** to draw the uncertainty fan (`buildBandPath` returns undefined below 2). +- **Cosmetic:** the fan band bridges straight across any interior lead-day with a null percentile, while the mean line + breaks at it — only visible if percentile columns are sparsely populated. diff --git a/docs/malawi-risk-watch/figures.md b/docs/malawi-risk-watch/figures.md new file mode 100644 index 0000000000..580d75ab69 --- /dev/null +++ b/docs/malawi-risk-watch/figures.md @@ -0,0 +1,75 @@ +# Malawi Risk Watch — Figures & Metrics Glossary + +Every number shown on the Malawi imminent-events page: what it means, its unit, where it comes from, and caveats. +See [`assumptions.md`](./assumptions.md) for the risks behind the ⚠ items. + +## JBA — forecast ensemble statistics (`band_5`) +Each JBA impact row holds statistics of `band_5` **across the 51 JBA ensemble members**, for one district at one +(issue date, target date) pair. Raw per-member values are **not** stored — only these aggregates + the non-zero count. + +| Figure | Meaning | Unit | Source | +|---|---|---|---| +| **Mean** (`band5Mean`) | Ensemble mean | ⚠ metres (m) **as labelled** — see [A1](./assumptions.md#a1--band_5-unit-flood-depth-vs-people-affected) | `FloodForecastImpact.band_5_mean` | +| **Median** (`band5Median`) | 50th percentile across members | ⚠ m | `band_5_median` | +| **P75** (`band5P75`) | 75th percentile | ⚠ m | `band_5_p75` | +| **P90** (`band5P90`) | 90th percentile | ⚠ m | `band_5_p90` | +| **Max** (`band5Max`) | Largest single member | ⚠ m | `band_5_max` | +| **Ensembles non-zero** (`ensemblesNonzeroCount`) | How many of the 51 members predicted any flooding | count (0–51) | `ensembles_nonzero_count` | + +> **Reading them:** percentiles describe **forecast uncertainty** (spread across the 51 members), not spatial/temporal +> spread. A P90 well above the Mean means a minority of members predict much higher values (right-skew / tail risk) — +> which is exactly what the [uncertainty fan chart](./adr/0005-impact-figures-fan-population.md) visualises. +> The `/51` denominator is **hard-coded** in the UI (A15). + +## JBA — forecast / lead-time fields +| Figure | Meaning | Unit | Notes | +|---|---|---|---| +| `leadTimeDays` | Days from issue → target | days (int 1–10) | ⚠ derived server-side (A14), not a column | +| `forecastIssueDate` | When the forecast was issued | date (YYYY-MM-DD) | keys impacts to a run (A12) | +| `forecastTargetDate` | The day being forecast | date | | + +## JBA — ingestion run (shown in the header select + InfoPopup) +| Figure | Meaning | Unit | +|---|---|---| +| `runDate` | Daily JBA fetch date (= forecast issue date, A12) | date — used as the select option label | +| `status` | Run status | enum: `pending` / `running` / `success` / `failed` / `partial` | +| `filesProcessed` / `filesExpected` | TIFFs ingested vs expected | counts, shown `processed / expected` | +| `forecastIssueTime` | Forecast issue timestamp | datetime (`yyyy-MM-dd, hh:mm`) | +| `completedAt` | Run completion timestamp | datetime (`yyyy-MM-dd, hh:mm`) | + +## JBA — per-district flood-exposed population (HDX, contextual) +From `MWI_ADM2_flood_exposure.csv`, **RP100 (1-in-100-year), 30 cm depth**. ⚠ Static context, **not** matched to the +forecast depth/lead time; the groups **overlap and are not additive** (A13). + +| Figure | CSV column | Unit | +|---|---|---| +| Under-15 | `RP100_pop_u15_30cm` | people (compact, e.g. `14K`) | +| Elderly (65+) | `RP100_elderly_30cm` | people | +| Female | `RP100_female_pop_30cm` | people | +| Under-5 | `RP100_children_u5_30cm` | people (not in the layer recipe, detail-only) | + +## HDX context layer metrics (choropleths) +Toggled in the **Layers** panel; legend bins are 5-bin quantiles (A20). All admin-2, joined by `ADM2_PCODE`. + +| Dataset | Metrics | Unit | +|---|---|---| +| Flood exposure (RP100) | under-15 / female / elderly exposed; hospitals %, education % | people (counts); `*_pct` = percent 0–100 | +| Vulnerability | pop_u15, female_pop, elderly; rural_pop_perc | people; rural = percent | +| Facilities | hospitals_count | count | +| Access | pop within 30 min of hospital / primary care, within 5 km of education | people | +| Demographics | pop_u15, elderly, female_pop | people | +| Rural population | rural_pop_perc; pop_u15_rural | percent; people | + +## ARC — rainfall observation +| Figure | Meaning | Unit | +|---|---|---| +| `rainfall` | Processed rainfall | millimetres (mm) | +| `rainfallRaw` | Raw rainfall | millimetres (mm) | +| `impact` | Derived impact score (`rainfall × factor`) | ⚠ unitless score (3 dp shown); not the real trigger (A3) | +| `eventRp` | Return period (set only when triggered) | years (discrete: 2, 5, 10, 20) | +| `cellTrigger` | Parametric cell trigger | boolean (`Active` / `Below trigger`); real rule `rainfall >= 25.4 mm` | + +## JBA COG raster overlay +Rendered client-side from the forecast TIFF for the active lead time (band 0). ⚠ Colours are **per-image normalised** +(relative, not absolute across lead times/runs) with **no legend or physical-value readout** (A19). Light-blue→red +ramp; `v<=0` transparent. Toggled via the raster control in EventDetails. diff --git a/malawi-risk-watch-backend b/malawi-risk-watch-backend index db869d2d90..71e26a32a2 160000 --- a/malawi-risk-watch-backend +++ b/malawi-risk-watch-backend @@ -1 +1 @@ -Subproject commit db869d2d9038417b7e3d460a1b711db5a29d2232 +Subproject commit 71e26a32a2dcd450aa150ce138cf95e14c6bf52f diff --git a/packages/ui/src/components/InputContainer/index.tsx b/packages/ui/src/components/InputContainer/index.tsx index 92a46f92b4..4aa3b90b70 100644 --- a/packages/ui/src/components/InputContainer/index.tsx +++ b/packages/ui/src/components/InputContainer/index.tsx @@ -114,7 +114,7 @@ function InputContainer(props: Props) { =14'} @@ -5187,6 +5196,10 @@ packages: geojson-vt@3.2.1: resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} + geotiff@3.0.5: + resolution: {integrity: sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==} + engines: {node: '>=10.19'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -5881,6 +5894,9 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + lerc@3.0.0: + resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} + levn@0.3.0: resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} engines: {node: '>= 0.8.0'} @@ -6424,6 +6440,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} @@ -6435,6 +6454,9 @@ packages: resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} engines: {node: '>=0.8'} + parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -6820,6 +6842,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + quickselect@2.0.0: resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} @@ -8031,6 +8057,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -8162,6 +8191,9 @@ packages: xml-name-validator@3.0.0: resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} + xml-utils@1.10.2: + resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -8216,6 +8248,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zstddec@0.2.0: + resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==} + snapshots: '@0no-co/graphql.web@1.2.0(graphql@16.14.0)': @@ -10073,6 +10108,8 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true + '@petamoriken/float16@3.9.3': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -13360,6 +13397,17 @@ snapshots: geojson-vt@3.2.1: {} + geotiff@3.0.5: + dependencies: + '@petamoriken/float16': 3.9.3 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.2.0 + get-caller-file@2.0.5: {} get-east-asian-width@1.5.0: {} @@ -14076,6 +14124,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + lerc@3.0.0: {} + levn@0.3.0: dependencies: prelude-ls: 1.1.2 @@ -14658,6 +14708,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + papaparse@5.5.3: {} parent-module@1.0.1: @@ -14670,6 +14722,8 @@ snapshots: map-cache: 0.2.2 path-root: 0.1.1 + parse-headers@2.0.6: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -15086,6 +15140,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-lru@6.1.2: {} + quickselect@2.0.0: {} react-clientside-effect@1.2.8(react@19.2.4): @@ -16446,6 +16502,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-worker@1.5.0: {} + webidl-conversions@4.0.2: optional: true @@ -16589,6 +16647,8 @@ snapshots: xml-name-validator@3.0.0: optional: true + xml-utils@1.10.2: {} + xmlchars@2.2.0: {} y18n@5.0.8: {} @@ -16629,3 +16689,5 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zstddec@0.2.0: {}