From 62a53bd8d20d0692e822d13797fe9cde312f2ad3 Mon Sep 17 00:00:00 2001 From: Zhonghao Zhang Date: Fri, 4 Sep 2026 18:19:59 -0700 Subject: [PATCH 1/4] feat: web frontend (React + MapLibre) with a thin FastAPI bridge over the public API --- frontend/.env.example | 5 + frontend/.gitignore | 5 + frontend/README.md | 121 ++ frontend/index.html | 23 + frontend/package-lock.json | 2126 +++++++++++++++++++ frontend/package.json | 30 + frontend/public/logo.svg | 24 + frontend/server/__init__.py | 1 + frontend/server/app.py | 509 +++++ frontend/server/preview.py | 108 + frontend/server/requirements.txt | 9 + frontend/server/tests/__init__.py | 1 + frontend/server/tests/test_app.py | 269 +++ frontend/src/App.tsx | 50 + frontend/src/components/ControlPanel.tsx | 395 ++++ frontend/src/components/MapPanel.tsx | 446 ++++ frontend/src/components/ParameterGroup.tsx | 166 ++ frontend/src/components/TimeseriesChart.tsx | 58 + frontend/src/index.css | 45 + frontend/src/main.tsx | 10 + frontend/src/store.ts | 201 ++ frontend/src/types.ts | 54 + frontend/src/ui.ts | 11 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 21 + frontend/vite.config.ts | 15 + 26 files changed, 4704 insertions(+) create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/logo.svg create mode 100644 frontend/server/__init__.py create mode 100644 frontend/server/app.py create mode 100644 frontend/server/preview.py create mode 100644 frontend/server/requirements.txt create mode 100644 frontend/server/tests/__init__.py create mode 100644 frontend/server/tests/test_app.py create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/ControlPanel.tsx create mode 100644 frontend/src/components/MapPanel.tsx create mode 100644 frontend/src/components/ParameterGroup.tsx create mode 100644 frontend/src/components/TimeseriesChart.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/store.ts create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/ui.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..549cfae6 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Absolute URL of a deployed server (frontend/server), no trailing slash. +# Leave empty for local development: Vite proxies /api to http://localhost:8000. +VITE_API_URL= +# Base path of the built site ("/" for root hosting). +VITE_BASE=/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..98c1b0d7 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env.local +__pycache__/ +.pytest_cache/ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..2496d93b --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,121 @@ +# SWMManywhere web frontend + +Contributed by [Zhonghao Zhang](https://github.com/Zhonghao1995). + +A browser interface for SWMManywhere: draw a bounding box on a map, adjust the +package's parameters, build, inspect the synthesised network on the map, click any +element for its attributes and simulated time series, and download the model +package. Every build is an ordinary SWMManywhere `config.yml`; the page is a form +for that file, and it shows you the file it used so the run can be reproduced with +`python -m swmmanywhere --config_path=...`. + +Nothing under `src/swmmanywhere` is modified. The folder is self-contained: + +| Path | What it is | +|---|---| +| `src/` | The web app: React, Vite, MapLibre GL, Zustand, Tailwind (TypeScript). | +| `server/` | A small FastAPI service that runs the package for the browser. It calls only the public API (`load_config`, `swmmanywhere`, `run` from `swmmanywhere.swmmanywhere`). | +| `server/tests/` | Contract tests that use the package's bundled Bellinge fixtures; no network, no SWMM. | + +A browser cannot run Python, so the service is the smallest possible bridge: it +writes the config, validates it with `load_config`, runs the synthesis in a worker +thread, runs the SWMM simulation with `run`, and serves the nodes / edges / +subcatchments as GeoJSON (outfalls identified the same way as +`swmmanywhere.utilities.plot_map`) plus per-element time series from the results. + +## Run it + +From the repository root, in the environment where `swmmanywhere` is installed: + +```bash +pip install -e . +pip install -r frontend/server/requirements.txt +cd frontend +uvicorn server.app:app --port 8000 # API on http://localhost:8000 +npm install && npm run dev # UI on http://localhost:5174 (proxies /api) +``` + +For a single-origin deployment build the app once (`npm run build`); the server +then serves `frontend/dist` at `/`. + +The page uses the docs' primary colour (`#00BCD4`, see `docs/custom.css`) as its +only accent and loads the Geist typeface from Google Fonts; drop the `` in +`index.html` for a fully offline build, the system font stack is the fallback. + +| Environment variable | Meaning | Default | +|---|---|---| +| `SWMMANYWHERE_WEB_WORKDIR` | `base_dir` for every build; downloads are reused per bounding box (SWMManywhere's `bbox_N/model_N` layout). | `/swmmanywhere_web` | +| `SWMMANYWHERE_WEB_MAX_KM2` | Largest bounding box accepted. | `5` | +| `SWMMANYWHERE_WEB_ORIGINS` | Comma-separated CORS origins. | `*` | + +Builds run one at a time: the package shares `base_dir` between runs and its logger +is process-global. The first build of a box spends a few minutes downloading +(NASADEM, Overture buildings, OpenStreetMap); rebuilding the same box with other +parameters takes about ten seconds for the documentation's Andorra example (0.6 km²), +including the SWMM run. + +## What the page exposes + +| Page section | Config key | Notes | +|---|---|---| +| Bounding box | `bbox` | Drawn with two clicks or typed; the area limit is enforced server-side. | +| Project | `project` | Folder name under `base_dir`. | +| Parameters | `parameter_overrides` | Rendered from the pydantic models in `swmmanywhere.parameters` (defaults, units, bounds, descriptions). Only changed values are sent. `metric_evaluation` is hidden because the page does not supply a `real` network. | +| Graph functions | `graphfcn_list` | The default list from `defs/demo_config.yml`, editable; the server validates it with `validate_graphfcn_list` via `load_config`. | +| Simulation | `run_model`, `run_settings` | `duration`, `reporting_iters`, `storevars` (the enum from `defs/schema.yml`). | + +Rainfall is the package's bundled demo storm (`defs/storm.dat`), exactly what the +CLI uses when no precipitation file is given. Real-network comparison (`real:`, +metrics), `starting_graph`, and custom modules are not exposed. + +The server additionally range-checks overrides with the package's own pydantic +models before building, because `swmmanywhere()` applies overrides with `setattr` +and only checks the names (issue #379). + +## HTTP surface + +| Method and path | Purpose | +|---|---| +| `GET /api/v1/defaults` | Parameter groups, graph functions, run settings, limits, all read from the package. | +| `POST /api/v1/tasks` | Start a build. Body: `bbox`, `project`, `parameter_overrides`, `graphfcn_list`, `run_model`, `run_settings`. Returns `task_id`. | +| `GET /api/v1/tasks/{id}` | State, stage, progress, last log lines, error. | +| `GET /api/v1/tasks/{id}/config` | The `config.yml` that was validated and run. | +| `GET /api/v1/tasks/{id}/preview` | GeoJSON (EPSG:4326) with `kind` = `junction`, `outfall`, `conduit`, `subcatchment`. | +| `GET /api/v1/tasks/{id}/timeseries?id=` | Simulated series for one SWMM object (`flooding`, `flow`, `depth`, `runoff`). | +| `GET /api/v1/tasks/{id}/result` | Zip of the model directory: `.inp`, nodes, edges, subcatchments, `config.yml`, `results.parquet`. The rain file is included and the `.inp` inside the zip refers to it by name so it opens in EPA SWMM as-is. | + +## Tests + +```bash +cd frontend +pytest server/tests # 8 tests, bundled fixtures only +npm run typecheck +``` + +## Things found while building this + +- **Overture release id.** `prepare_data._get_latest_s3_url` derives the release + from `Path(href).parent` of the STAC catalog's child links. Those hrefs are now + absolute URLs, so the package caches `https:/stac.overturemaps.org/` + and every buildings download fails on a non-existent S3 key. The server seeds + `.cache/overture_release.json` with a well-formed id before each build; the + package itself is unchanged. This wants a one-line fix upstream. +- **`affine` 3.** With `affine>=3` (where `Affine` is no longer a namedtuple), + `pyflwdir.dem.slope`, a numba function SWMManywhere calls with a raster + transform in `geospatial_utilities`, fails with "Cannot determine Numba type of + " during `clip_to_catchments`. `server/requirements.txt` + pins `affine<3`; the package's own dependencies probably want the same pin until + `pyflwdir` supports affine 3. +- **macOS on Apple silicon.** `import pyswmm` is killed by the kernel because + `libomp.dylib` inside the `swmm-toolkit` wheel has an invalid code signature. + Re-signing the wheel's binaries fixes it: + `codesign --force --sign - /swmm/toolkit/*.dylib /swmm/toolkit/*.so`. +- **Paths with spaces.** SWMM cannot read a `[RAINGAGES] FILE` path containing a + space. The server copies the demo storm into its work directory and the + downloaded zip references it by bare name. +- **Subcatchment slope units (question for the maintainers).** `derive_subcatchments` + stores the mean `pyflwdir.dem.slope` gradient, which that function documents as + m/m, and `synthetic_write` copies it unchanged into the `%Slope` column of + `[SUBCATCHMENTS]`, which SWMM reads as a percentage. For the Andorra example the + written values are 0.0–0.9 where 0–89 % would be expected. The map shows the + stored value labelled m/m. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..5c959fee --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + SWMManywhere + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..e6b26cb4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2126 @@ +{ + "name": "swmmanywhere-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swmmanywhere-frontend", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^1.17.0", + "maplibre-gl": "^5.24.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-map-gl": "^8.1.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@types/geojson": "^7946.0.16", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.0.tgz", + "integrity": "sha512-g45M8gEI4sMO3X9ib4K7n3ZKFf0qQOL+NMkHCnEK51lOrYFHiEssOuZncx1+miIgi1IEE2NcbRMcq8RBkL+QHA==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vis.gl/react-mapbox": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.3.tgz", + "integrity": "sha512-96sCSl8BKoTLn2xMWlkjlR9GA5URM2t5Ln386t8pNVsoINsUXxweyuJ+gJlDe0GgAIOttXreU/1fK9AfrD28NA==", + "license": "MIT", + "peerDependencies": { + "mapbox-gl": ">=3.5.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.3.tgz", + "integrity": "sha512-5Ntvp8YP94SBRaJXLolw6Q57CtrPdWYxdCIo+JeI63hM9BPEJ1efLcPgfSAAUhzXanDeorTE91GsZAgoXbnB3g==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^19.2.1" + }, + "peerDependencies": { + "maplibre-gl": ">=4.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "maplibre-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", + "dependencies": { + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" + } + }, + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.41.0.tgz", + "integrity": "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-map-gl": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.3.tgz", + "integrity": "sha512-i8ExMBodZ5waBlnnXhPHfubf1mSQrzVBKsp9LUSWIAQjOuUhI4Dv962rzE2QfI7n5oywunldQlxcW1Mjy8rp7w==", + "license": "MIT", + "dependencies": { + "@vis.gl/react-mapbox": "8.1.3", + "@vis.gl/react-maplibre": "8.1.3" + }, + "peerDependencies": { + "mapbox-gl": ">=1.13.0", + "maplibre-gl": ">=1.13.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + }, + "maplibre-gl": { + "optional": true + } + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typewise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" + } + }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..4356464e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "swmmanywhere-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "lucide-react": "^1.17.0", + "maplibre-gl": "^5.24.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-map-gl": "^8.1.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@types/geojson": "^7946.0.16", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 00000000..2f43b9b9 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/server/__init__.py b/frontend/server/__init__.py new file mode 100644 index 00000000..05e358d4 --- /dev/null +++ b/frontend/server/__init__.py @@ -0,0 +1 @@ +"""Web service for the SWMManywhere frontend (see frontend/README.md).""" diff --git a/frontend/server/app.py b/frontend/server/app.py new file mode 100644 index 00000000..5df0d208 --- /dev/null +++ b/frontend/server/app.py @@ -0,0 +1,509 @@ +"""Web service that runs SWMManywhere for the browser frontend in ../src. + +It uses only the package's public Python API (``load_config``, ``swmmanywhere`` and +``run`` from ``swmmanywhere.swmmanywhere``) and never patches anything in ``src/``. +Every build writes the same ``config.yml`` a CLI user would pass to +``python -m swmmanywhere``, validates it with ``load_config`` and runs it; the +frontend is a form for that file. + +Run from the ``frontend/`` directory:: + + uvicorn server.app:app --port 8000 + +Environment: + SWMMANYWHERE_WEB_WORKDIR build directory (default: /swmmanywhere_web) + SWMMANYWHERE_WEB_MAX_KM2 largest bounding box accepted, km² (default: 5) + SWMMANYWHERE_WEB_ORIGINS comma-separated CORS origins (default: *) +""" + +from __future__ import annotations + +import importlib.metadata +import json +import math +import os +import re +import shutil +import tempfile +import threading +import traceback +import uuid +import zipfile +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +import requests +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.staticfiles import StaticFiles +from jsonschema import ValidationError as SchemaError +from pydantic import BaseModel, Field, ValidationError + +import swmmanywhere +from swmmanywhere import parameters +from swmmanywhere.graph_utilities import graphfcns +from swmmanywhere.logging import logger +from swmmanywhere.swmmanywhere import load_config +from swmmanywhere.swmmanywhere import run as run_simulation +from swmmanywhere.swmmanywhere import swmmanywhere as run_synthesis +from swmmanywhere.utilities import yaml_dump, yaml_load + +from .preview import element_timeseries, model_geojson + +DEFS = Path(swmmanywhere.__file__).parent / "defs" +DIST = Path(__file__).resolve().parent.parent / "dist" +PROJECT_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + +# Log lines SWMManywhere emits along the way (swmmanywhere.py, preprocessing.py), +# mapped to a label and a rough percentage for the progress bar. +STAGES: list[tuple[str, str, float]] = [ + ("Running downloads", "Downloading open data", 5), + ("downloading elevation", "Downloading elevation (NASADEM)", 6), + ("downloading buildings", "Downloading buildings (Overture)", 9), + ("downloading network", "Downloading streets (OpenStreetMap)", 12), + ("downloading river", "Downloading rivers (OpenStreetMap)", 15), + ("Iterating graph functions", "Running graph functions", 20), + ("Saving final graph", "Writing the SWMM .inp", 82), + ("initialised in pyswmm", "Running the SWMM simulation", 86), + ("Model run complete", "Simulation complete", 96), +] +GRAPHFCN_DONE = re.compile(r"graphfcn: (\S+) completed") +# `FILE ""` or `FILE ` in a [RAINGAGES] line. +RAINGAGE_FILE = re.compile(r'(FILE\s+)("([^"]+)"|(\S+))') + +# Overture release cache. ``swmmanywhere.prepare_data._get_latest_s3_url`` derives +# the release id as ``Path(href).parent`` of the newest child link in the Overture +# STAC catalog. Those hrefs are absolute URLs now, so it caches +# "https:/stac.overturemaps.org/2026-08-19.0" in ./.cache/overture_release.json and +# every buildings download then fails on a non-existent S3 key. The cache is read +# before the catalog, so seeding it with a well-formed id (fresh timestamp) lets the +# unmodified package download buildings. Drop this once it is fixed upstream. +RELEASE_ID = re.compile(r"\d{4}-\d{2}-\d{2}\.\d+") +OVERTURE_CATALOG = "https://stac.overturemaps.org/catalog.json" + + +def _fetch_overture_catalog() -> dict: + response = requests.get(OVERTURE_CATALOG, timeout=15) + response.raise_for_status() + return response.json() + + +def latest_overture_release(catalog: dict) -> str | None: + """Newest release id among the catalog's child links, e.g. '2026-08-19.0'.""" + ids = [] + for link in catalog.get("links", []): + m = ( + RELEASE_ID.search(str(link.get("href", ""))) + if link.get("rel") == "child" + else None + ) + if m: + ids.append(m.group(0)) + return max(ids) if ids else None + + +def seed_overture_cache( + workdir: Path, fetch_catalog: Callable[[], dict] = _fetch_overture_catalog +) -> str | None: + """Write a well-formed Overture release id to the cache the package reads. + + Offline, the id embedded in an existing (possibly malformed) cache is reused. + """ + cache = workdir / ".cache" / "overture_release.json" + try: + release = latest_overture_release(fetch_catalog()) + except Exception: # noqa: BLE001 — offline is fine if a cache exists + m = RELEASE_ID.search(cache.read_text()) if cache.exists() else None + release = m.group(0) if m else None + if release: + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text( + json.dumps({"release": release, "timestamp": datetime.now().isoformat()}) + ) + return release + + +class SubmitBody(BaseModel): + """What the frontend posts; everything maps onto a SWMManywhere config key.""" + + bbox: list[float] = Field(min_length=4, max_length=4) + project: str = "swmmanywhere_web" + parameter_overrides: dict[str, dict[str, Any]] = Field(default_factory=dict) + graphfcn_list: list[str] | None = None + run_model: bool = True + run_settings: dict[str, Any] | None = None + + +@dataclass +class Task: + """One build: its validated config, progress and outputs.""" + + id: str + config: dict + config_text: str + run_model: bool + n_graphfcns: int + state: str = "QUEUED" # QUEUED | RUNNING | SUCCEEDED | FAILED + stage: str = "Queued" + progress_pct: float = 0.0 + log: list[str] = field(default_factory=list) + error: str | None = None + model_dir: Path | None = None + results: Path | None = None + preview: dict | None = None + zip_path: Path | None = None + + +def bbox_area_km2(bbox: list[float]) -> float: + """Approximate area of a lon/lat box, enough for the size guard.""" + minx, miny, maxx, maxy = bbox + mid_lat = math.radians((miny + maxy) / 2) + return (maxx - minx) * 111.32 * math.cos(mid_lat) * (maxy - miny) * 110.57 + + +def validate_bbox(bbox: list[float], max_km2: float) -> list[float]: + """Reject a malformed or oversized bounding box with a 422.""" + if not all(math.isfinite(v) for v in bbox): + raise HTTPException(422, "bbox must be four finite numbers.") + minx, miny, maxx, maxy = bbox + if not (-180 <= minx < maxx <= 180 and -90 <= miny < maxy <= 90): + raise HTTPException( + 422, + "bbox must be [min lon, min lat, max lon, max lat] in EPSG:4326 " + "with min < max.", + ) + area = bbox_area_km2(bbox) + if area > max_km2: + raise HTTPException( + 422, + f"Bounding box is {area:.1f} km²; this server accepts at most " + f"{max_km2:g} km².", + ) + return bbox.copy() + + +def validate_overrides(overrides: dict[str, dict[str, Any]]) -> None: + """Range-check overrides with the package's own pydantic parameter models. + + ``swmmanywhere.swmmanywhere`` applies overrides with ``setattr`` and only checks + that the names exist (issue #379), so a web user could otherwise submit values + outside the documented bounds without noticing. + """ + groups = parameters.get_full_parameters() + for category, values in overrides.items(): + if category not in groups: + raise HTTPException( + 422, + f"{category} is not a parameter group. " + f"Must be one of {sorted(groups)}.", + ) + model = groups[category] + current = {k: v for k, v in model.model_dump().items() if v is not None} + try: + type(model).model_validate(current | values) + except ValidationError as exc: + problems = "; ".join( + f"{'.'.join(str(p) for p in e['loc']) or category}: {e['msg']}" + for e in exc.errors() + ) + raise HTTPException(422, f"Invalid {category} override — {problems}") + + +def parameter_groups() -> list[dict]: + """Parameter groups with fields, defaults and bounds, from the package.""" + groups = [] + for name, model in parameters.get_full_parameters().items(): + fields = [ + { + "name": key, + "type": prop.get("type", "unknown"), + "default": prop.get("default"), + "unit": prop.get("unit"), + "description": " ".join((prop.get("description") or "").split()), + "minimum": prop.get("minimum"), + "maximum": prop.get("maximum"), + "exclusiveMaximum": prop.get("exclusiveMaximum"), + } + for key, prop in model.model_json_schema()["properties"].items() + ] + groups.append( + {"name": name, "doc": (type(model).__doc__ or "").strip(), "fields": fields} + ) + return groups + + +def package_zip(task: Task) -> Path: + """Zip the model directory; the rain file is added and referenced by bare name. + + SWMManywhere writes the absolute path of the rain file into ``[RAINGAGES]``, which + only resolves on the machine that built the model. The copy inside the zip points + at the file next to it so the download opens and runs in EPA SWMM as-is. + """ + assert task.model_dir is not None + zip_path = task.model_dir / "model_package.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z: + for f in sorted(task.model_dir.iterdir()): + if not f.is_file() or f == zip_path: + continue + if f.suffix != ".inp": + z.write(f, f.name) + continue + lines = [] + for line in f.read_text().splitlines(): + m = RAINGAGE_FILE.search(line) + if m: + path = Path(m.group(3) or m.group(4)) + if path.is_absolute() and path.exists(): + z.write(path, path.name) + line = ( + line[: m.start()] + f'FILE "{path.name}"' + line[m.end() :] + ) + lines.append(line) + z.writestr(f.name, "\n".join(lines) + "\n") + return zip_path + + +def create_app( + *, + synthesis: Callable[[dict], tuple[Path, Any]] = run_synthesis, + simulate: Callable[..., Any] = run_simulation, + prepare: Callable[[Path], Any] = seed_overture_cache, + workdir: Path | str | None = None, + run_inline: bool = False, +) -> FastAPI: + """Build the FastAPI app. + + ``synthesis``, ``simulate`` and ``prepare`` are injectable so tests run without + downloads or SWMM; ``run_inline`` runs tasks synchronously in the request. + """ + workdir = Path( + workdir + or os.environ.get("SWMMANYWHERE_WEB_WORKDIR") + or Path(tempfile.gettempdir()) / "swmmanywhere_web" + ).resolve() + workdir.mkdir(parents=True, exist_ok=True) + # The package's bundled demo storm, copied out so the path never contains spaces + # (SWMM cannot read a [RAINGAGES] FILE path with spaces) and so a future upload has + # an obvious place to go. Same file the CLI falls back to. + precipitation = workdir / "precipitation.dat" + if not precipitation.exists(): + shutil.copyfile(DEFS / "storm.dat", precipitation) + max_km2 = float(os.environ.get("SWMMANYWHERE_WEB_MAX_KM2", "5")) + schema = yaml_load((DEFS / "schema.yml").read_text()) + demo = load_config(validation=False) # package defaults (defs/demo_config.yml) + + app = FastAPI(title="SWMManywhere web") + origins = [ + o.strip() + for o in os.environ.get("SWMMANYWHERE_WEB_ORIGINS", "*").split(",") + if o.strip() + ] + app.add_middleware( + CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"] + ) + + tasks: dict[str, Task] = {} + lock = threading.Lock() + # One build at a time: SWMManywhere shares the base_dir between builds (it reuses + # downloads per bbox and numbers models), and loguru is process-global. + executor = ThreadPoolExecutor(max_workers=1) + + def on_log(task: Task, message: str) -> None: + line = message.rstrip() + if not line: + return + with lock: + task.log.append(line) + del task.log[:-300] + for needle, label, pct in STAGES: + if needle in line: + task.stage, task.progress_pct = label, pct + m = GRAPHFCN_DONE.search(line) + if m: + done = sum(1 for entry in task.log if GRAPHFCN_DONE.search(entry)) + task.stage = f"Graph function {done}/{task.n_graphfcns}: {m.group(1)}" + task.progress_pct = 20 + 60 * done / max(task.n_graphfcns, 1) + + def run_task(task: Task) -> None: + # SWMManywhere writes temporary DEM tiles and the WhiteboxTools download into + # the current directory; keep those in the workdir. + os.chdir(workdir) + task.state, task.stage = "RUNNING", "Starting" + handler = logger.add( + lambda message: on_log(task, str(message)), + filter=lambda record: True, + format="{message}", + ) + try: + prepare(workdir) + inp, _ = synthesis(task.config) + if inp.suffix != ".inp": + raise RuntimeError( + "SWMManywhere derived no pipes for this bounding box (the graph " + "has no edges). Try a larger or more built-up area." + ) + task.model_dir = inp.parent + (task.model_dir / "config.yml").write_text(task.config_text) + if task.run_model: + task.stage, task.progress_pct = "Running the SWMM simulation", 85 + results = simulate(inp, **task.config["run_settings"]) + task.results = task.model_dir / "results.parquet" + results.to_parquet(task.results) + task.stage, task.progress_pct, task.state = "Complete", 100, "SUCCEEDED" + except Exception as exc: # noqa: BLE001 — any failure must surface in the UI + task.state, task.stage = "FAILED", "Failed" + task.error = f"{type(exc).__name__}: {exc}" + traceback.print_exc() # full trace in the server log + finally: + logger.remove(handler) + + def get_task(task_id: str) -> Task: + task = tasks.get(task_id) + if task is None: + raise HTTPException(404, "Unknown task.") + return task + + @app.get("/api/v1/healthz") + def healthz(): + return { + "status": "ok", + "swmmanywhere": importlib.metadata.version("swmmanywhere"), + } + + @app.get("/api/v1/defaults") + def defaults(): + return { + "version": importlib.metadata.version("swmmanywhere"), + "parameters": parameter_groups(), + "graphfcn_list": demo["graphfcn_list"], + "graphfcns": { + name: (fn.__doc__ or "").strip().splitlines()[0] if fn.__doc__ else "" + for name, fn in sorted(graphfcns.items()) + }, + "run_settings": demo["run_settings"], + "storevars": schema["properties"]["run_settings"]["properties"][ + "storevars" + ]["items"]["enum"], + "max_area_km2": max_km2, + } + + @app.post("/api/v1/tasks", status_code=202) + def submit(body: SubmitBody): + bbox = validate_bbox(body.bbox, max_km2) + if not PROJECT_RE.match(body.project): + raise HTTPException( + 422, "project must be 1–64 letters, digits, '-' or '_'." + ) + validate_overrides(body.parameter_overrides) + + # The config a CLI user would write, then SWMManywhere's own validation. + config: dict[str, Any] = { + "base_dir": str(workdir), + "project": body.project, + "bbox": bbox, + "run_model": body.run_model, + "run_settings": {**demo["run_settings"], **(body.run_settings or {})}, + "address_overrides": {"precipitation": str(precipitation)}, + } + overrides = {k: v for k, v in body.parameter_overrides.items() if v} + if overrides: + config["parameter_overrides"] = overrides + if body.graphfcn_list is not None: + config["graphfcn_list"] = body.graphfcn_list + config_text = yaml_dump(config) + + task_id = uuid.uuid4().hex[:12] + task_dir = workdir / "tasks" / task_id + task_dir.mkdir(parents=True) + config_path = task_dir / "config.yml" + config_path.write_text(config_text) + try: + loaded = load_config(config_path) + except SchemaError as exc: + raise HTTPException( + 422, f"SWMManywhere rejected the configuration: {exc.message}" + ) + except (ValueError, TypeError, AssertionError, FileNotFoundError) as exc: + raise HTTPException(422, f"SWMManywhere rejected the configuration: {exc}") + # The simulation is run here (see run_task) so its results can be served. + loaded["run_model"] = False + + task = Task( + id=task_id, + config=loaded, + config_text=config_text, + run_model=body.run_model, + n_graphfcns=len(loaded.get("graphfcn_list") or demo["graphfcn_list"]), + ) + with lock: + tasks[task_id] = task + if run_inline: + run_task(task) + else: + executor.submit(run_task, task) + return {"task_id": task_id, "status": task.state} + + @app.get("/api/v1/tasks/{task_id}") + def status(task_id: str): + task = get_task(task_id) + with lock: + return { + "state": task.state, + "stage": task.stage, + "progress_pct": task.progress_pct, + "log": task.log[-8:], + "error": task.error, + } + + @app.get("/api/v1/tasks/{task_id}/config") + def config_yaml(task_id: str): + return PlainTextResponse(get_task(task_id).config_text, media_type="text/yaml") + + @app.get("/api/v1/tasks/{task_id}/preview") + def preview(task_id: str): + task = get_task(task_id) + if task.state != "SUCCEEDED" or task.model_dir is None: + raise HTTPException(409, "Preview not ready.") + if task.preview is None: + task.preview = model_geojson(task.model_dir) + return task.preview + + @app.get("/api/v1/tasks/{task_id}/result") + def result(task_id: str): + task = get_task(task_id) + if task.state != "SUCCEEDED" or task.model_dir is None: + raise HTTPException(409, "Result not ready.") + if task.zip_path is None: + task.zip_path = package_zip(task) + return FileResponse( + str(task.zip_path), + media_type="application/zip", + filename="swmmanywhere_model.zip", + ) + + @app.get("/api/v1/tasks/{task_id}/timeseries") + def timeseries(task_id: str, id: str): + task = get_task(task_id) + if task.state != "SUCCEEDED": + raise HTTPException(409, "Results not ready.") + if task.results is None: + raise HTTPException( + 409, "This build did not run the model (run_model was off)." + ) + series = element_timeseries(task.results, id) + if not series: + raise HTTPException(404, f"No simulation results stored for {id}.") + return series + + # Serve the built frontend (npm run build) from the same origin when present. + if DIST.is_dir(): + app.mount("/", StaticFiles(directory=str(DIST), html=True), name="frontend") + + return app + + +app = create_app() diff --git a/frontend/server/preview.py b/frontend/server/preview.py new file mode 100644 index 00000000..55707061 --- /dev/null +++ b/frontend/server/preview.py @@ -0,0 +1,108 @@ +"""Turn a SWMManywhere model directory into GeoJSON and per-element time series. + +Reads the same nodes / edges / subcatchments files ``swmmanywhere.utilities.plot_map`` +reads and identifies outfalls the same way (``plot_basic``), so the map matches what the +package's own folium plot would show. +""" + +from __future__ import annotations + +import math +from contextlib import suppress +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +from shapely.geometry import mapping + +from swmmanywhere.utilities import read_df + + +def _first(model_dir: Path, stem: str) -> Path | None: + return next(iter(sorted(model_dir.glob(f"{stem}.*"))), None) + + +def _clean(value: Any) -> Any: + """JSON-safe scalar: numpy -> python, NaN/inf -> None, anything odd -> str.""" + if isinstance(value, np.generic): + value = value.item() + if isinstance(value, float): + return value if math.isfinite(value) else None + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, (list, tuple, np.ndarray)): + return [_clean(v) for v in value] + with suppress(TypeError, ValueError): + if pd.isna(value): + return None + return str(value) + + +def _features(df: pd.DataFrame, kind: str, **extra: pd.Series) -> list[dict]: + """One GeoJSON feature per row, every non-geometry column as a property.""" + columns = [c for c in df.columns if c != "geometry"] + features = [] + for i, (_, row) in enumerate(df.iterrows()): + props = {c: _clean(row[c]) for c in columns} + props["kind"] = kind + for name, series in extra.items(): + props[name] = _clean(series.iloc[i]) + features.append( + {"type": "Feature", "geometry": mapping(row.geometry), "properties": props} + ) + return features + + +def model_geojson(model_dir: Path) -> dict: + """GeoJSON (EPSG:4326) of a synthesised model with a ``kind`` per feature. + + kinds: ``junction``, ``outfall``, ``conduit``, ``subcatchment``. Feature ``id`` + is the SWMM object name so it matches ``results.parquet`` (subcatchments are + named ``-sub`` in the .inp, see ``post_processing.synthetic_write``). + """ + nodes_fid, edges_fid = _first(model_dir, "nodes"), _first(model_dir, "edges") + if nodes_fid is None or edges_fid is None: + raise FileNotFoundError("No nodes or edges found in model directory.") + nodes = read_df(nodes_fid).to_crs(4326) + edges = read_df(edges_fid).to_crs(4326) + + node_ids = nodes["id"].astype(str) + # Same rule as swmmanywhere.utilities.plot_basic. + if "outfall" in nodes.columns: + is_outfall = node_ids == nodes["outfall"].astype(str) + else: + is_outfall = ~node_ids.isin(edges["u"].astype(str)) + + features: list[dict] = [] + subs_fid = _first(model_dir, "subcatchments") + if subs_fid is not None: + subs = read_df(subs_fid).to_crs(4326) + outlet = subs["id"].astype(str) + subs = subs.assign(id=outlet + "-sub") + features += _features(subs, "subcatchment", outlet=outlet) + + edges = edges.assign( + id=edges["id"].astype(str), u=edges["u"].astype(str), v=edges["v"].astype(str) + ) + features += _features(edges, "conduit") + nodes = nodes.assign(id=node_ids) + features += _features(nodes.loc[~is_outfall], "junction") + features += _features(nodes.loc[is_outfall], "outfall") + return {"type": "FeatureCollection", "features": features} + + +def element_timeseries( + results_path: Path, element_id: str +) -> dict[str, dict[str, list]]: + """``{variable: {dates: [...iso...], values: [...]}}`` for one SWMM object.""" + df = pd.read_parquet(results_path) + sel = df[df["id"].astype(str) == element_id] + out: dict[str, dict[str, list]] = {} + for variable, grp in sel.groupby("variable"): + grp = grp.sort_values("date") + out[str(variable)] = { + "dates": [pd.Timestamp(d).isoformat() for d in grp["date"]], + "values": [float(v) for v in grp["value"]], + } + return out diff --git a/frontend/server/requirements.txt b/frontend/server/requirements.txt new file mode 100644 index 00000000..e62c84a0 --- /dev/null +++ b/frontend/server/requirements.txt @@ -0,0 +1,9 @@ +# On top of the swmmanywhere package itself (pip install -e . from the repo root). +fastapi>=0.110 +uvicorn>=0.29 +# affine 3 turned Affine into a plain class; pyflwdir's numba-compiled `slope` +# (called from swmmanywhere.geospatial_utilities) cannot type it. Pin until upstream does. +affine<3 +# tests +pytest>=7 +httpx>=0.27 diff --git a/frontend/server/tests/__init__.py b/frontend/server/tests/__init__.py new file mode 100644 index 00000000..c2a37361 --- /dev/null +++ b/frontend/server/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the SWMManywhere web service.""" diff --git a/frontend/server/tests/test_app.py b/frontend/server/tests/test_app.py new file mode 100644 index 00000000..dc9eea87 --- /dev/null +++ b/frontend/server/tests/test_app.py @@ -0,0 +1,269 @@ +"""Contract tests for frontend/server using SWMManywhere's bundled Bellinge fixtures. + +No network and no SWMM run: synthesis and simulation are injected fakes that write +the package's own test data (src/swmmanywhere/defs/bellinge_small_*) into the model +directory. Config validation, GeoJSON conversion, packaging and time-series +extraction are the real code. +""" + +from __future__ import annotations + +import io +import json +import shutil +import zipfile +from datetime import datetime, timedelta +from pathlib import Path + +import pandas as pd +import pytest +from fastapi.testclient import TestClient + +import swmmanywhere +from server.app import create_app, seed_overture_cache +from swmmanywhere.utilities import load_graph, save_graph_to_features + +DEFS = Path(swmmanywhere.__file__).parent / "defs" +CATALOG = { + "links": [ + {"rel": "self", "href": "https://stac.overturemaps.org/catalog.json"}, + { + "rel": "child", + "title": "Latest Overture Release", + "href": "https://stac.overturemaps.org/2026-08-19.0/catalog.json", + }, + { + "rel": "child", + "title": "2026-07-22.0 Overture Release", + "href": "https://stac.overturemaps.org/2026-07-22.0/catalog.json", + }, + ] +} +BBOX = [10.290, 55.318, 10.300, 55.324] # small; the fake synthesis ignores it +NODE = "G72F820" # a node of bellinge_small_graph.json + + +def fake_synthesis(config: dict): + """Write the Bellinge fixtures where SWMManywhere would put a model.""" + model_dir = Path(config["base_dir"]) / config["project"] / "bbox_1" / "model_1" + model_dir.mkdir(parents=True, exist_ok=True) + graph = load_graph(DEFS / "bellinge_small_graph.json") + save_graph_to_features( + graph, + model_dir / "nodes.geojson", + model_dir / "edges.geojson", + graph.graph["crs"], + ) + shutil.copy( + DEFS / "bellinge_small_subcatchments.geojson", + model_dir / "subcatchments.geojson", + ) + rain = config["address_overrides"]["precipitation"] + inp = model_dir / "model_1.inp" + inp.write_text( + "[TITLE]\nfake\n\n[RAINGAGES]\n;;Name Format Interval SCF Source\n" + f'1 INTENSITY 0:15 1.0 FILE "{rain}" 1 MM\n\n[JUNCTIONS]\n' + ) + return inp, None + + +def fake_simulate( + model: Path, reporting_iters: int, duration: int, storevars: list[str] +): + """Return a short flooding series for one node instead of running SWMM.""" + t0 = datetime(2000, 1, 1) + rows = [ + { + "date": t0 + timedelta(minutes=15 * i), + "value": float(i), + "variable": "flooding", + "id": NODE, + } + for i in range(8) + ] + return pd.DataFrame(rows) + + +def no_edges_synthesis(config: dict): + """Mimic SWMManywhere returning the graph file when no pipes were derived.""" + model_dir = Path(config["base_dir"]) / config["project"] / "bbox_1" / "model_1" + model_dir.mkdir(parents=True, exist_ok=True) + return model_dir / "graph.parquet", None + + +@pytest.fixture +def client(tmp_path): + """A test client whose builds run inline on the Bellinge fixtures.""" + app = create_app( + synthesis=fake_synthesis, + simulate=fake_simulate, + prepare=lambda _: None, + workdir=tmp_path, + run_inline=True, + ) + return TestClient(app) + + +def test_overture_cache_is_seeded_with_a_well_formed_release_id(tmp_path): + """The cache the package reads gets a plain release id, online or offline.""" + cache = tmp_path / ".cache" / "overture_release.json" + assert ( + seed_overture_cache(tmp_path, fetch_catalog=lambda: CATALOG) == "2026-08-19.0" + ) + assert json.loads(cache.read_text())["release"] == "2026-08-19.0" + # Offline: the id embedded in the package's malformed cache is reused. + cache.write_text( + json.dumps( + { + "release": "https:/stac.overturemaps.org/2026-07-22.0", + "timestamp": "2026-09-01T00:00:00", + } + ) + ) + + def offline(): + raise ConnectionError("no network") + + assert seed_overture_cache(tmp_path, fetch_catalog=offline) == "2026-07-22.0" + assert json.loads(cache.read_text())["release"] == "2026-07-22.0" + + +def test_defaults_come_from_the_package(client): + """Parameter schema, graphfcn list and run settings are read from the package.""" + d = client.get("/api/v1/defaults").json() + groups = {g["name"]: g for g in d["parameters"]} + assert { + "subcatchment_derivation", + "outfall_derivation", + "topology_derivation", + "hydraulic_design", + } <= set(groups) + lane_width = next( + f + for f in groups["subcatchment_derivation"]["fields"] + if f["name"] == "lane_width" + ) + assert ( + lane_width["default"] == 3.5 + and lane_width["unit"] == "m" + and lane_width["minimum"] == 2.0 + ) + assert ( + d["graphfcn_list"][0] == "assign_id" + and d["graphfcn_list"][-2] == "fix_geometries" + ) + assert "pipe_by_pipe" in d["graphfcns"] + assert "flooding" in d["storevars"] and d["run_settings"]["duration"] == 86400 + + +@pytest.mark.parametrize( + "bbox, fragment", + [([1.0, 2.0, 0.5, 3.0], "min < max"), ([0.0, 0.0, 1.0, 1.0], "km²")], +) +def test_bad_bbox_is_rejected(client, bbox, fragment): + """Reversed corners and oversized boxes are refused before any build starts.""" + r = client.post("/api/v1/tasks", json={"bbox": bbox}) + assert r.status_code == 422 and fragment in r.json()["detail"] + + +def test_parameter_overrides_are_range_checked(client): + """Out-of-range values and unknown groups are refused with the field named.""" + r = client.post( + "/api/v1/tasks", + json={"bbox": BBOX, "parameter_overrides": {"hydraulic_design": {"max_fr": 5}}}, + ) + assert r.status_code == 422 and "max_fr" in r.json()["detail"] + r = client.post( + "/api/v1/tasks", json={"bbox": BBOX, "parameter_overrides": {"nope": {"x": 1}}} + ) + assert r.status_code == 422 and "nope" in r.json()["detail"] + + +def test_unknown_graphfcn_is_rejected_by_swmmanywhere(client): + """The package's own graphfcn validation runs on the submitted list.""" + r = client.post( + "/api/v1/tasks", json={"bbox": BBOX, "graphfcn_list": ["assign_id", "teleport"]} + ) + assert r.status_code == 422 and "teleport" in r.json()["detail"] + + +def test_build_preview_results_and_package(client, tmp_path): + """A build yields status, config, GeoJSON preview, time series and a zip.""" + r = client.post( + "/api/v1/tasks", + json={ + "bbox": BBOX, + "project": "bellinge", + "parameter_overrides": {"outfall_derivation": {"method": "withtopo"}}, + "run_settings": { + "duration": 3600, + "reporting_iters": 10, + "storevars": ["flooding"], + }, + }, + ) + assert r.status_code == 202, r.text + task_id = r.json()["task_id"] + + status = client.get(f"/api/v1/tasks/{task_id}").json() + assert status["state"] == "SUCCEEDED", status + assert status["progress_pct"] == 100 + + cfg = client.get(f"/api/v1/tasks/{task_id}/config").text + assert "bbox:" in cfg and "withtopo" in cfg and "duration: 3600" in cfg + assert (tmp_path / "bellinge" / "bbox_1" / "model_1" / "config.yml").exists() + + fc = client.get(f"/api/v1/tasks/{task_id}/preview").json() + kinds = {f["properties"]["kind"] for f in fc["features"]} + assert kinds == {"junction", "outfall", "conduit", "subcatchment"} + outfalls = [f for f in fc["features"] if f["properties"]["kind"] == "outfall"] + assert len(outfalls) >= 1 + lon, lat = outfalls[0]["geometry"]["coordinates"] + assert 10 < lon < 11 and 55 < lat < 56 # reprojected from EPSG:32632 to lon/lat + sub = next(f for f in fc["features"] if f["properties"]["kind"] == "subcatchment") + assert sub["properties"]["id"].endswith("-sub") and sub["properties"]["outlet"] + node = next(f for f in fc["features"] if f["properties"]["id"] == NODE) + assert node["properties"]["surface_elevation"] == pytest.approx(26.129, abs=1e-3) + + series = client.get( + f"/api/v1/tasks/{task_id}/timeseries", params={"id": NODE} + ).json() + assert list(series) == ["flooding"] and series["flooding"]["values"][-1] == 7.0 + assert ( + client.get( + f"/api/v1/tasks/{task_id}/timeseries", params={"id": "nowhere"} + ).status_code + == 404 + ) + + z = zipfile.ZipFile( + io.BytesIO(client.get(f"/api/v1/tasks/{task_id}/result").content) + ) + names = set(z.namelist()) + assert { + "model_1.inp", + "nodes.geojson", + "edges.geojson", + "config.yml", + "results.parquet", + "precipitation.dat", + } <= names + assert 'FILE "precipitation.dat"' in z.read("model_1.inp").decode() + + +def test_no_edges_fails_with_a_clear_message(tmp_path): + """A graph without edges ends as FAILED with an explanation, not a traceback.""" + app = create_app( + synthesis=no_edges_synthesis, + simulate=fake_simulate, + prepare=lambda _: None, + workdir=tmp_path, + run_inline=True, + ) + client = TestClient(app) + task_id = client.post( + "/api/v1/tasks", json={"bbox": BBOX, "run_model": False} + ).json()["task_id"] + status = client.get(f"/api/v1/tasks/{task_id}").json() + assert status["state"] == "FAILED" and "no pipes" in status["error"] + assert client.get(f"/api/v1/tasks/{task_id}/preview").status_code == 409 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..d1351bd7 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react' +import ControlPanel from './components/ControlPanel' +import MapPanel from './components/MapPanel' + +const MIN_W = 320 +const MAX_W = 680 + +export default function App() { + // Resizable control panel: drag the divider to widen or narrow it. + const [width, setWidth] = useState(400) + + const startResize = (e: React.PointerEvent) => { + e.preventDefault() + const startX = e.clientX + const startW = width + const onMove = (ev: PointerEvent) => { + setWidth(Math.min(MAX_W, Math.max(MIN_W, startW + (ev.clientX - startX)))) + } + const onUp = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + } + + return ( +
+
+ +
+
+
+
+
+ +
+
+ ) +} diff --git a/frontend/src/components/ControlPanel.tsx b/frontend/src/components/ControlPanel.tsx new file mode 100644 index 00000000..fa9d5580 --- /dev/null +++ b/frontend/src/components/ControlPanel.tsx @@ -0,0 +1,395 @@ +import { useEffect, useMemo, useState } from 'react' +import { ChevronRight, Download, Loader2, LocateFixed, Play, RotateCcw, SquareDashed, Trash2 } from 'lucide-react' +import { bboxAreaKm2, graphfcnLines, useStore } from '../store' +import type { Bbox } from '../types' +import { BTN_PRIMARY, ICON_BTN, INPUT, LABEL } from '../ui' +import ParameterGroup from './ParameterGroup' + +const BBOX_LABELS = ['min lon', 'min lat', 'max lon', 'max lat'] + +// metric_evaluation only applies when a real network is supplied for comparison +// (config `real:`), which this form does not do. +const HIDDEN_GROUPS = new Set(['metric_evaluation']) + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +export default function ControlPanel() { + const defaults = useStore((s) => s.defaults) + const defaultsError = useStore((s) => s.defaultsError) + const loadDefaults = useStore((s) => s.loadDefaults) + const bbox = useStore((s) => s.bbox) + const drawing = useStore((s) => s.drawing) + const anchor = useStore((s) => s.anchor) + const startDraw = useStore((s) => s.startDraw) + const cancelDraw = useStore((s) => s.cancelDraw) + const setBbox = useStore((s) => s.setBbox) + const requestFit = useStore((s) => s.requestFit) + const project = useStore((s) => s.project) + const setProject = useStore((s) => s.setProject) + const overrides = useStore((s) => s.overrides) + const setOverride = useStore((s) => s.setOverride) + const resetGroup = useStore((s) => s.resetGroup) + const graphfcnText = useStore((s) => s.graphfcnText) + const setGraphfcnText = useStore((s) => s.setGraphfcnText) + const runModel = useStore((s) => s.runModel) + const setRunModel = useStore((s) => s.setRunModel) + const runSettings = useStore((s) => s.runSettings) + const setRunSettings = useStore((s) => s.setRunSettings) + const job = useStore((s) => s.job) + const submit = useStore((s) => s.submit) + const preview = useStore((s) => s.preview) + const configYaml = useStore((s) => s.configYaml) + const [showLog, setShowLog] = useState(false) + // Typed bbox: the four boxes are free text until all of them parse, so a CLI + // user can paste numbers without drawing first. + const [bboxDraft, setBboxDraft] = useState(['', '', '', '']) + + useEffect(() => { + void loadDefaults() + }, [loadDefaults]) + + useEffect(() => { + if (!bbox) setBboxDraft(['', '', '', '']) + else if (bboxDraft.some((t, i) => Number(t) !== bbox[i])) setBboxDraft(bbox.map(String)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bbox]) + + const counts = useMemo(() => { + const c = { subcatchment: 0, junction: 0, outfall: 0, conduit: 0 } + preview?.features.forEach((f) => { + const kind = (f.properties as { kind?: string } | null)?.kind + if (kind && kind in c) c[kind as keyof typeof c]++ + }) + return c + }, [preview]) + + const busy = job.status === 'queued' || job.status === 'running' + const areaKm2 = bbox ? bboxAreaKm2(bbox) : 0 + const tooLarge = !!defaults && areaKm2 > defaults.max_area_km2 + const graphfcnChanged = !!defaults && graphfcnText.trim() !== defaults.graphfcn_list.join('\n') + const storevarChoices = defaults?.storevars ?? runSettings.storevars + + const editBbox = (i: number, text: string) => { + const next = [...bboxDraft] + next[i] = text + setBboxDraft(next) + const nums = next.map(Number) + if (next.every((t) => t.trim() !== '') && nums.every(Number.isFinite)) setBbox(nums as Bbox) + } + + const toggleStorevar = (v: string) => + setRunSettings({ + storevars: runSettings.storevars.includes(v) + ? runSettings.storevars.filter((s) => s !== v) + : [...runSettings.storevars, v], + }) + + return ( +