diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bdcbc46 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Bake the in-browser simulator into the bundle and skip the server probe. +# GitHub Pages sets this. Leave it unset locally so `npm run dev` uses the Node simulator. +# VITE_FORCE_BROWSER=true + +# Optional absolute WebSocket URL. Defaults to the page host + /ws (Vite proxies /ws in dev). +# VITE_WS_URL=ws://127.0.0.1:8787/ws + +# Port for the Node simulator. +# PORT=8787 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8fe22b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install + run: npm ci + - name: Lint + run: npm run lint + - name: Typecheck + run: npm run typecheck + - name: Unit tests + run: npm test + - name: Install Playwright + run: npx playwright install --with-deps chromium + - name: Smoke test + run: npm run test:e2e diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..38c9f28 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,45 @@ +name: Deploy GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: actions/configure-pages@v5 + - run: npm ci + - name: Build static demo + run: npm run build + env: + VITE_FORCE_BROWSER: 'true' + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7c95c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +coverage +playwright-report +test-results +blob-report +.DS_Store +*.local +.env +.env.* +!.env.example diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..90742fc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +coverage +playwright-report +test-results +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a067b6a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 96, + "singleQuote": true, + "semi": true, + "trailingComma": "all" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce06fe5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Arpan Bom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3e9ce7e..4250139 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,106 @@ -# react-ts-fleet-dashboard -React + TypeScript real-time fleet monitoring dashboard +# Meridian + +Real-time fleet operations console for ground vehicles and drones. This is a **portfolio demo** by [Arpan Bom](https://github.com/applycoding). The tracks, batteries, and alerts are simulated. Nothing here is connected to a live vehicle, a drone remote ID feed, or an operations center. + +[![CI](https://github.com/applycoding/react-ts-fleet-dashboard/actions/workflows/ci.yml/badge.svg)](https://github.com/applycoding/react-ts-fleet-dashboard/actions/workflows/ci.yml) + +The same deterministic simulator runs in two places: + +- a small Node server with HTTP snapshots and a WebSocket tick, for local development +- the browser itself, when that server is not there (including the GitHub Pages build) + +## Screenshots + +![Meridian dashboard in dark mode, with the bay map, unit list, telemetry, and alerts](docs/screenshots/desktop-dark.png) + +![The same dashboard pinned to light mode](docs/screenshots/desktop-light.png) + +![Stacked layout on a phone-width viewport](docs/screenshots/mobile-dark.png) + +## Features + +- Live map (Leaflet, CARTO basemaps from OpenStreetMap data) with moving ground and drone markers and a restricted-airspace box +- Unit list with search, kind, and status filters +- Telemetry for the selected unit: speed, battery, altitude, and heading over the last few minutes +- Alerts for low battery, intermittent links, and drones inside the restricted box, with acknowledge +- Light, dark, and system color schemes, without a flash of the wrong scheme +- Layout that stacks on narrow viewports +- Vitest and Testing Library coverage, plus a Playwright smoke test +- GitHub Actions CI and a GitHub Pages workflow that ships the in-browser simulator + +## Architecture + +```mermaid +flowchart LR + subgraph browser [Browser] + UI[React panels] + Z[Zustand UI state] + Q[TanStack Query] + SIM[In-browser engine] + UI --> Z + UI --> Q + Q -->|server missing| SIM + end + + subgraph node [Optional Node simulator] + HTTP[HTTP /api] + WS[WebSocket /ws] + ENG[Shared engine] + HTTP --> ENG + WS --> ENG + end + + Q -->|local dev| HTTP + Q -->|local dev| WS +``` + +`src/sim` is the shared engine. Position is a function of time along a fixed route, so the server and the browser draw the same tracks. The React app keeps fleet snapshots and history in TanStack Query. Filters, the selected unit, acknowledgements, and the data-source mode live in Zustand. + +On startup the page asks `/api/health`. A response means “live socket”. A miss, or `VITE_FORCE_BROWSER=true` in the Pages build, means the tab runs the engine itself. + +## Stack + +React 18, TypeScript, Vite, TanStack Query, Zustand, Leaflet, Vitest, Testing Library, Playwright, ESLint, Prettier. + +## Run it + +```bash +npm install +npm run dev +``` + +- App: http://localhost:5173 +- Simulator: http://127.0.0.1:8787 (`/api/health`, `/api/snapshot`, `/api/units/:id/history`, `POST /api/alerts/:id/ack`, WebSocket `/ws`) + +The Vite dev server proxies `/api` and `/ws` to the simulator. Stop the simulator and reload: the badge switches to **Browser sim** and the map keeps moving. + +```bash +npm run dev:server # simulator only +npm run dev:client # Vite only +npm run build +npm run preview +``` + +`npm run build` writes a static `dist/` that does not need the Node process. + +## Tests + +```bash +npm run lint +npm test +npm run test:e2e +``` + +`test:e2e` builds the static app, serves it, and checks that the in-browser fleet renders and the drone filter works. CI runs lint, the typecheck, unit tests, and that smoke test. + +## GitHub Pages + +`.github/workflows/pages.yml` builds with `VITE_FORCE_BROWSER=true` and deploys `dist/` with GitHub Pages actions. In the repository settings, set **Pages → Build and deployment → Source** to **GitHub Actions**. After the first deploy on `main` the site is at: + +https://applycoding.github.io/react-ts-fleet-dashboard/ + +Map tiles are loaded from CARTO’s public basemap host. That is fine for a demo; it is not a tile service you should put in front of real traffic. + +## License + +[MIT](LICENSE) © 2026 Arpan Bom diff --git a/docs/screenshots/desktop-dark.png b/docs/screenshots/desktop-dark.png new file mode 100644 index 0000000..8a94901 Binary files /dev/null and b/docs/screenshots/desktop-dark.png differ diff --git a/docs/screenshots/desktop-light.png b/docs/screenshots/desktop-light.png new file mode 100644 index 0000000..c5f18fa Binary files /dev/null and b/docs/screenshots/desktop-light.png differ diff --git a/docs/screenshots/mobile-dark.png b/docs/screenshots/mobile-dark.png new file mode 100644 index 0000000..13644bf Binary files /dev/null and b/docs/screenshots/mobile-dark.png differ diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..2b926fc --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; + +test('shows the simulated fleet and filters to drones', async ({ page }) => { + await page.goto('./'); + await expect(page.getByRole('heading', { level: 1, name: 'Meridian' })).toBeVisible(); + await expect(page.getByRole('option', { name: /M-14/ })).toBeVisible(); + await expect(page.getByText('Browser sim')).toBeVisible(); + + await page + .getByRole('group', { name: 'Unit kind' }) + .getByRole('button', { name: 'Drones' }) + .click(); + await expect(page.getByRole('option', { name: /M-14/ })).toHaveCount(0); + await expect(page.getByRole('option', { name: /D-12/ })).toBeVisible(); + + await page.getByRole('option', { name: /D-12/ }).click(); + await expect(page.getByRole('region', { name: 'Telemetry' })).toContainText('D-12'); + await expect(page.getByRole('img', { name: /Speed in km\/h/ })).toBeVisible(); +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c98b276 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,35 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: [ + 'dist/**', + 'node_modules/**', + 'playwright-report/**', + 'test-results/**', + 'coverage/**', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, + prettier, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..f78e504 --- /dev/null +++ b/index.html @@ -0,0 +1,35 @@ + + + + + + + + Meridian — Fleet operations + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..43c48f8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4799 @@ +{ + "name": "meridian-fleet", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meridian-fleet", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@tanstack/react-query": "^5.104.2", + "leaflet": "^1.9.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.64.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", + "@types/leaflet": "^1.9.22", + "@types/node": "^26.6.5", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@types/ws": "^8.18.2", + "@vitejs/plugin-react": "^6.1.2", + "concurrently": "^10.0.6", + "eslint": "^10.12.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "jsdom": "^29.1.1", + "prettier": "^3.9.10", + "tsx": "^4.23.15", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", + "vitest": "^5.0.4", + "ws": "^8.22.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "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.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "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/@tanstack/query-core": { + "version": "5.104.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.2.tgz", + "integrity": "sha512-/qbsxJK5c3Ve1aFIULWYNcT2jeRUAiD4HME6Eu2ZdpofHoVgAjpkQhLg5xHuOoBkTPQUJLfP7zOxy0qsBcqROA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.2.tgz", + "integrity": "sha512-xKmzWGhadbE5wGS/ZmXShRITnI9oItox5GO14KvFZco3+vhTAyXS1erYzJWdiY280zapAGJXFUL9nHgwP6SV/g==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "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.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz", + "integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.12.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.456", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.456.tgz", + "integrity": "sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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/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/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/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/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/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/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/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/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/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/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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.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.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "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.7.1", + "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/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "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/package.json b/package.json new file mode 100644 index 0000000..88a05a6 --- /dev/null +++ b/package.json @@ -0,0 +1,64 @@ +{ + "name": "meridian-fleet", + "private": true, + "version": "1.0.0", + "description": "Portfolio demo: real-time vehicle and drone fleet monitor with a simulated telemetry feed.", + "author": "Arpan Bom (https://github.com/applycoding)", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/applycoding/react-ts-fleet-dashboard" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "concurrently -n server,client -c cyan,magenta \"npm run dev:server\" \"npm run dev:client\"", + "dev:client": "vite", + "dev:server": "tsx watch server/index.ts", + "build": "npm run typecheck && vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "test": "vitest run", + "test:e2e": "playwright test", + "pretest:e2e": "npm run build", + "lint": "eslint . && prettier --check .", + "format": "prettier --write .", + "capture": "tsx scripts/capture-screenshots.ts" + }, + "dependencies": { + "@tanstack/react-query": "^5.104.2", + "leaflet": "^1.9.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.64.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", + "@types/leaflet": "^1.9.22", + "@types/node": "^26.6.5", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@types/ws": "^8.18.2", + "@vitejs/plugin-react": "^6.1.2", + "concurrently": "^10.0.6", + "eslint": "^10.12.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "jsdom": "^29.1.1", + "prettier": "^3.9.10", + "tsx": "^4.23.15", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", + "vitest": "^5.0.4", + "ws": "^8.22.0" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..134c8d6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'e2e', + timeout: 60_000, + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'npm run preview -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..dc70004 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/capture-screenshots.ts b/scripts/capture-screenshots.ts new file mode 100644 index 0000000..0b19245 --- /dev/null +++ b/scripts/capture-screenshots.ts @@ -0,0 +1,56 @@ +import { mkdir } from 'node:fs/promises'; +import { chromium, type Page } from '@playwright/test'; + +const base = process.env.CAPTURE_URL ?? 'http://127.0.0.1:4173'; + +async function waitForMap(page: Page): Promise { + await page + .getByRole('option', { name: /M-14|D-12/ }) + .first() + .waitFor(); + await page.evaluate('document.fonts.ready'); + try { + await page.waitForFunction( + `() => [...document.querySelectorAll('img.leaflet-tile')].some((tile) => tile.complete && tile.naturalWidth > 0)`, + undefined, + { timeout: 12_000 }, + ); + } catch { + // A screenshot without tiles is still useful if the tile host is slow. + } + await page.waitForTimeout(400); +} + +async function prepare(page: Page, scheme: 'light' | 'dark'): Promise { + await page.goto(base); + await page + .getByRole('button', { name: scheme === 'dark' ? 'Dark' : 'Light', exact: true }) + .click(); + await page.getByRole('option', { name: /D-12/ }).click(); + await waitForMap(page); +} + +async function main(): Promise { + await mkdir('docs/screenshots', { recursive: true }); + const browser = await chromium.launch(); + + const desktop = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await prepare(desktop, 'dark'); + await desktop.screenshot({ path: 'docs/screenshots/desktop-dark.png' }); + + await prepare(desktop, 'light'); + await desktop.screenshot({ path: 'docs/screenshots/desktop-light.png' }); + await desktop.close(); + + const mobile = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await prepare(mobile, 'dark'); + await mobile.screenshot({ path: 'docs/screenshots/mobile-dark.png', fullPage: true }); + await mobile.close(); + + await browser.close(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..24559e9 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,15 @@ +import { createSimulatorServer } from './simulator'; + +const parsed = Number(process.env.PORT ?? 8787); +const port = Number.isFinite(parsed) ? parsed : 8787; +const { server, close } = createSimulatorServer(); + +server.listen(port, () => { + console.log(`Meridian simulator on http://127.0.0.1:${port}`); +}); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + void close().then(() => process.exit(0)); + }); +} diff --git a/server/server.test.ts b/server/server.test.ts new file mode 100644 index 0000000..3bd8d13 --- /dev/null +++ b/server/server.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; +import { createSimulatorServer } from './simulator'; + +describe('simulator server', () => { + let closeServer: (() => Promise) | undefined; + + afterEach(async () => { + await closeServer?.(); + closeServer = undefined; + }); + + async function start(): Promise { + const simulator = createSimulatorServer(); + closeServer = simulator.close; + await new Promise((resolve) => { + simulator.server.listen(0, '127.0.0.1', () => resolve()); + }); + const address = simulator.server.address(); + if (!address || typeof address === 'string') + throw new Error('Simulator did not bind a port'); + return address.port; + } + + it('serves health, history, and acknowledgement', async () => { + const port = await start(); + const health = await fetch(`http://127.0.0.1:${port}/api/health`); + expect(health.ok).toBe(true); + + const snapshot = (await fetch(`http://127.0.0.1:${port}/api/snapshot`).then((response) => + response.json(), + )) as { units: unknown[]; alerts: { id: string }[] }; + expect(snapshot.units).toHaveLength(14); + expect(snapshot.alerts.length).toBeGreaterThan(0); + + const history = (await fetch( + `http://127.0.0.1:${port}/api/units/m-14/history?points=4`, + ).then((response) => response.json())) as { samples: unknown[] }; + expect(history.samples).toHaveLength(4); + + const missing = await fetch(`http://127.0.0.1:${port}/api/units/nope/history`); + expect(missing.status).toBe(404); + + const id = snapshot.alerts[0]?.id ?? ''; + const ack = await fetch( + `http://127.0.0.1:${port}/api/alerts/${encodeURIComponent(id)}/ack`, + { + method: 'POST', + }, + ); + expect(ack.ok).toBe(true); + const next = (await fetch(`http://127.0.0.1:${port}/api/snapshot`).then((response) => + response.json(), + )) as { alerts: { id: string }[] }; + expect(next.alerts.map((alert) => alert.id)).not.toContain(id); + }); + + it('pushes a snapshot over the socket', async () => { + const port = await start(); + const message = await new Promise<{ type?: string; units?: unknown[] }>( + (resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const timer = setTimeout(() => { + socket.close(); + reject(new Error('socket timeout')); + }, 3_000); + socket.on('message', (data) => { + clearTimeout(timer); + socket.close(); + resolve(JSON.parse(String(data)) as { type?: string; units?: unknown[] }); + }); + socket.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }, + ); + expect(message.type).toBe('snapshot'); + expect(message.units).toHaveLength(14); + }); +}); diff --git a/server/simulator.ts b/server/simulator.ts new file mode 100644 index 0000000..d095a8b --- /dev/null +++ b/server/simulator.ts @@ -0,0 +1,114 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { WebSocket, WebSocketServer } from 'ws'; +import { HISTORY_POINTS, createEngine } from '../src/sim/engine'; + +function writeJson(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET,POST,OPTIONS', + 'access-control-allow-headers': 'content-type', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify(body)); +} + +function decodePart(value: string): string | undefined { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} + +function clampPoints(raw: string | null): number { + const parsed = Number(raw ?? HISTORY_POINTS); + if (!Number.isFinite(parsed)) return HISTORY_POINTS; + return Math.min(120, Math.max(2, Math.round(parsed))); +} + +export function createSimulatorServer() { + const engine = createEngine(); + + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + if (request.method === 'OPTIONS') { + writeJson(response, 204, {}); + return; + } + + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + const { pathname } = url; + + if (request.method === 'GET' && pathname === '/api/health') { + writeJson(response, 200, { ok: true }); + return; + } + + if (request.method === 'GET' && pathname === '/api/snapshot') { + writeJson(response, 200, engine.snapshot()); + return; + } + + const historyMatch = pathname.match(/^\/api\/units\/([^/]+)\/history$/); + if (request.method === 'GET' && historyMatch) { + const unitId = decodePart(historyMatch[1] ?? ''); + if (!unitId) { + writeJson(response, 400, { error: 'Bad unit id' }); + return; + } + const points = clampPoints(url.searchParams.get('points')); + const samples = engine.history(unitId, Date.now(), points); + if (samples.length === 0) { + writeJson(response, 404, { error: 'Unknown unit' }); + return; + } + writeJson(response, 200, { unitId, samples }); + return; + } + + const ackMatch = pathname.match(/^\/api\/alerts\/([^/]+)\/ack$/); + if (request.method === 'POST' && ackMatch) { + const id = decodePart(ackMatch[1] ?? ''); + if (!id) { + writeJson(response, 400, { error: 'Bad alert id' }); + return; + } + engine.acknowledge(id); + writeJson(response, 200, { ok: true }); + return; + } + + writeJson(response, 404, { error: 'Not found' }); + }); + + const sockets = new WebSocketServer({ server, path: '/ws' }); + + const broadcast = () => { + const payload = JSON.stringify({ type: 'tick', ...engine.snapshot() }); + for (const client of sockets.clients) { + if (client.readyState === WebSocket.OPEN) client.send(payload); + } + }; + + sockets.on('connection', (socket) => { + socket.send(JSON.stringify({ type: 'snapshot', ...engine.snapshot() })); + }); + + const timer = setInterval(broadcast, 1000); + + const close = () => + new Promise((resolve, reject) => { + clearInterval(timer); + for (const client of sockets.clients) client.close(); + sockets.close(); + server.close((error) => { + if (error && (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') { + reject(error); + return; + } + resolve(); + }); + }); + + return { server, close }; +} diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..d038fc5 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,52 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { App } from './App'; + +vi.mock('./components/MapPanel', async () => { + const React = await import('react'); + return { + MapPanel: () => React.createElement('section', { 'aria-label': 'Live map' }), + }; +}); + +function renderApp() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render(createElement(QueryClientProvider, { client }, createElement(App))); +} + +describe('App', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('offline'))), + ); + }); + + it('falls back to the in-browser fleet and filters drones', async () => { + const user = userEvent.setup(); + renderApp(); + + expect( + await screen.findByRole('option', { name: /M-14/ }, { timeout: 3000 }), + ).toBeInTheDocument(); + expect(screen.getByText('Browser sim')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Live map' })).toBeInTheDocument(); + + await user.click( + within(screen.getByRole('group', { name: 'Unit kind' })).getByRole('button', { + name: 'Drones', + }), + ); + expect(screen.queryByRole('option', { name: /M-14/ })).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: /D-12/ })).toBeInTheDocument(); + + await user.click(screen.getByRole('option', { name: /D-12/ })); + expect(screen.getByRole('region', { name: 'Telemetry' })).toHaveTextContent('D-12'); + expect(screen.getByRole('img', { name: /Speed in km\/h/ })).toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..49aaaeb --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,110 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { AlertsPanel } from './components/AlertsPanel'; +import { FleetList } from './components/FleetList'; +import { MapPanel } from './components/MapPanel'; +import { TelemetryPanel } from './components/TelemetryPanel'; +import { TopBar } from './components/TopBar'; +import { browserEngine } from './data/browserEngine'; +import { useDataMode, useFleetStream } from './data/useFleetLink'; +import { isHistory, isSnapshot } from './guards'; +import { sortUnits } from './filters'; +import { useUiStore } from './store/ui'; +import type { FleetSnapshot } from './types'; + +async function loadSnapshot(mode: 'live' | 'browser'): Promise { + if (mode === 'browser') return browserEngine.snapshot(); + const response = await fetch('/api/snapshot'); + if (!response.ok) throw new Error(`Snapshot failed (${response.status})`); + const body: unknown = await response.json(); + if (!isSnapshot(body)) throw new Error('Snapshot payload was not valid'); + return body; +} + +async function loadHistory(mode: 'live' | 'browser', unitId: string) { + if (mode === 'browser') return browserEngine.history(unitId); + const response = await fetch(`/api/units/${encodeURIComponent(unitId)}/history?points=48`); + if (!response.ok) throw new Error(`History failed (${response.status})`); + const body: unknown = await response.json(); + if (!isHistory(body)) throw new Error('History payload was not valid'); + return body.samples; +} + +export function App() { + useDataMode(); + useFleetStream(); + + const mode = useUiStore((state) => state.dataMode); + const linkNote = useUiStore((state) => state.linkNote); + const selectedId = useUiStore((state) => state.selectedId); + const setSelectedId = useUiStore((state) => state.setSelectedId); + const acknowledged = useUiStore((state) => state.acknowledged); + const acknowledge = useUiStore((state) => state.acknowledge); + + const fleet = useQuery({ + queryKey: ['fleet', mode], + enabled: mode === 'live' || mode === 'browser', + queryFn: () => loadSnapshot(mode === 'live' ? 'live' : 'browser'), + }); + + const history = useQuery({ + queryKey: ['history', mode, selectedId], + enabled: Boolean(selectedId) && (mode === 'live' || mode === 'browser'), + queryFn: () => loadHistory(mode === 'live' ? 'live' : 'browser', selectedId ?? ''), + refetchInterval: 5_000, + }); + + const units = fleet.data?.units ?? []; + + useEffect(() => { + if (selectedId) return; + const next = fleet.data?.units; + if (!next || next.length === 0) return; + const first = sortUnits(next)[0]; + if (first) setSelectedId(first.id); + }, [fleet.data, selectedId, setSelectedId]); + + const selected = units.find((unit) => unit.id === selectedId) ?? null; + const alerts = (fleet.data?.alerts ?? []).filter((alert) => !acknowledged[alert.id]); + const loading = mode === 'detecting' || fleet.isPending; + + const onAcknowledge = (id: string) => { + acknowledge(id); + if (mode !== 'live') return; + void fetch(`/api/alerts/${encodeURIComponent(id)}/ack`, { method: 'POST' }).catch(() => { + // The alert stays hidden locally even if the simulator is gone. + }); + }; + + return ( +
+ +
+ { + void fleet.refetch(); + }} + /> + + + +
+
+ ); +} diff --git a/src/components/AlertsPanel.test.tsx b/src/components/AlertsPanel.test.tsx new file mode 100644 index 0000000..23dbfca --- /dev/null +++ b/src/components/AlertsPanel.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import type { Alert } from '../types'; +import { AlertsPanel } from './AlertsPanel'; + +const alert: Alert = { + id: 'link:m-09', + unitId: 'm-09', + callsign: 'M-09', + severity: 'warning', + code: 'link-degraded', + message: 'M-09 telemetry link is intermittent', + at: 1_000, +}; + +describe('AlertsPanel', () => { + it('acknowledges a specific alert', async () => { + const user = userEvent.setup(); + const onAcknowledge = vi.fn(); + render(); + expect(screen.getByText('M-09 telemetry link is intermittent')).toBeInTheDocument(); + expect(screen.getByText(/11s ago/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Acknowledge M-09 Link' })); + expect(onAcknowledge).toHaveBeenCalledWith('link:m-09'); + }); + + it('shows the quiet state', () => { + render( undefined} />); + expect(screen.getByText(/No open alerts/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/AlertsPanel.tsx b/src/components/AlertsPanel.tsx new file mode 100644 index 0000000..ecccab8 --- /dev/null +++ b/src/components/AlertsPanel.tsx @@ -0,0 +1,50 @@ +import { alertCodeLabel, formatAge, severityLabel } from '../format'; +import type { Alert } from '../types'; + +export function AlertsPanel({ + alerts, + now, + onAcknowledge, +}: { + alerts: Alert[]; + now: number; + onAcknowledge: (id: string) => void; +}) { + return ( +
+
+

Alerts

+

+ {alerts.length} open +

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

No open alerts. Degraded links and low batteries show up here.

+ ) : ( +
+
    + {alerts.map((alert) => ( +
  • +

    + {severityLabel(alert.severity)} · {alertCodeLabel(alert.code)} +

    +

    {alert.message}

    +

    + {alert.callsign} · {formatAge(alert.at, now)} +

    + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/FleetList.test.tsx b/src/components/FleetList.test.tsx new file mode 100644 index 0000000..8de9570 --- /dev/null +++ b/src/components/FleetList.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { createEngine } from '../sim/engine'; +import { useUiStore } from '../store/ui'; +import { FleetList } from './FleetList'; + +const snapshot = createEngine().snapshot(1_700_000_000_000); + +function renderList() { + return render( + undefined} />, + ); +} + +describe('FleetList', () => { + it('filters by search, kind, and status', async () => { + const user = userEvent.setup(); + renderList(); + + expect(screen.getByRole('option', { name: /M-14/ })).toBeInTheDocument(); + await user.type( + screen.getByRole('searchbox', { name: 'Search callsign, route, or payload' }), + 'wharf', + ); + expect(screen.getByRole('option', { name: /M-41/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /M-14/ })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + await user.click( + within(screen.getByRole('group', { name: 'Unit kind' })).getByRole('button', { + name: 'Drones', + }), + ); + expect(screen.queryByRole('option', { name: /M-14/ })).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: /D-12/ })).toBeInTheDocument(); + + await user.click( + within(screen.getByRole('group', { name: 'Unit kind' })).getByRole('button', { + name: 'All', + }), + ); + await user.click( + within(screen.getByRole('group', { name: 'Unit status' })).getByRole('button', { + name: 'Alert', + }), + ); + expect(screen.getByRole('option', { name: /M-09/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /M-03/ })).not.toBeInTheDocument(); + }); + + it('selects a unit', async () => { + const user = userEvent.setup(); + renderList(); + await user.click(screen.getByRole('option', { name: /D-12/ })); + expect(useUiStore.getState().selectedId).toBe('d-12'); + expect(screen.getByRole('option', { name: /D-12/ })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); +}); diff --git a/src/components/FleetList.tsx b/src/components/FleetList.tsx new file mode 100644 index 0000000..2c374f1 --- /dev/null +++ b/src/components/FleetList.tsx @@ -0,0 +1,203 @@ +import { filterUnits, filtersAreActive, sortUnits } from '../filters'; +import { kindLabel, statusLabel } from '../format'; +import { useUiStore } from '../store/ui'; +import type { FleetUnit, UnitKind, UnitStatus } from '../types'; +import { DroneIcon, GroundIcon } from './icons'; + +const KINDS: Array<'all' | UnitKind> = ['all', 'ground', 'drone']; +const STATUSES: Array<'all' | UnitStatus> = ['all', 'enroute', 'idle', 'charging', 'alert']; + +function kindChipLabel(kind: 'all' | UnitKind): string { + switch (kind) { + case 'all': + return 'All'; + case 'ground': + return 'Ground'; + case 'drone': + return 'Drones'; + default: { + const unreachable: never = kind; + return unreachable; + } + } +} + +function statusChipLabel(status: 'all' | UnitStatus): string { + switch (status) { + case 'all': + return 'All'; + case 'enroute': + case 'idle': + case 'charging': + case 'alert': + return statusLabel(status); + default: { + const unreachable: never = status; + return unreachable; + } + } +} + +function meterClass(batteryPct: number): string { + if (batteryPct < 12) return 'meter is-critical'; + if (batteryPct < 20) return 'meter is-low'; + return 'meter'; +} + +export function FleetList({ + units, + loading, + error, + onRetry, +}: { + units: FleetUnit[]; + loading: boolean; + error: string | null; + onRetry: () => void; +}) { + const search = useUiStore((state) => state.search); + const kind = useUiStore((state) => state.kind); + const status = useUiStore((state) => state.status); + const selectedId = useUiStore((state) => state.selectedId); + const setSearch = useUiStore((state) => state.setSearch); + const setKind = useUiStore((state) => state.setKind); + const setStatus = useUiStore((state) => state.setStatus); + const setSelectedId = useUiStore((state) => state.setSelectedId); + const clearFilters = useUiStore((state) => state.clearFilters); + + const filters = { search, kind, status }; + const visible = sortUnits(filterUnits(units, filters)); + const selectionHidden = + Boolean(selectedId) && !visible.some((unit) => unit.id === selectedId); + + return ( +
+
+

Units

+

+ {loading ? 'Loading' : `${visible.length} of ${units.length}`} +

+
+
+ +
+ {KINDS.map((option) => ( + + ))} +
+
+ {STATUSES.map((option) => ( + + ))} +
+ {filtersAreActive(filters) ? ( + + ) : null} +
+ {loading ? ( +
+ ); +} diff --git a/src/components/MapPanel.tsx b/src/components/MapPanel.tsx new file mode 100644 index 0000000..006fc7f --- /dev/null +++ b/src/components/MapPanel.tsx @@ -0,0 +1,162 @@ +import L from 'leaflet'; +import { useEffect, useRef, useState } from 'react'; +import { + MapContainer, + Marker, + Popup, + Rectangle, + TileLayer, + Tooltip, + useMap, +} from 'react-leaflet'; +import { kindLabel, statusLabel } from '../format'; +import { preferenceFromMeta, readMetaContent, resolveScheme } from '../theme'; +import type { FleetUnit, RestrictedZone } from '../types'; +import { useSystemScheme } from '../useSystemScheme'; + +function useResolvedScheme(): 'light' | 'dark' { + const system = useSystemScheme(); + const [meta, setMeta] = useState(readMetaContent); + + useEffect(() => { + const element = document.querySelector('meta[name="color-scheme"]'); + if (!element) return; + const observer = new MutationObserver(() => setMeta(readMetaContent())); + observer.observe(element, { attributes: true, attributeFilter: ['content'] }); + return () => observer.disconnect(); + }, []); + + return resolveScheme(preferenceFromMeta(meta), system); +} + +function pinIcon(unit: FleetUnit, selected: boolean): L.DivIcon { + const selectedClass = selected ? ' is-selected' : ''; + return L.divIcon({ + className: 'pin-wrap', + html: ``, + iconSize: [22, 22], + iconAnchor: [11, 11], + }); +} + +function FitOnce({ units }: { units: FleetUnit[] }) { + const map = useMap(); + const fitted = useRef(false); + + useEffect(() => { + if (fitted.current || units.length === 0) return; + const bounds = L.latLngBounds(units.map((unit) => [unit.lat, unit.lng])); + map.fitBounds(bounds.pad(0.18)); + fitted.current = true; + }, [map, units]); + + return null; +} + +function FollowSelection({ unit }: { unit: FleetUnit | null }) { + const map = useMap(); + const skipInitial = useRef(true); + const lastId = useRef(null); + + useEffect(() => { + if (!unit) return; + if (skipInitial.current) { + skipInitial.current = false; + lastId.current = unit.id; + return; + } + if (lastId.current === unit.id) return; + lastId.current = unit.id; + const target: L.LatLngExpression = [unit.lat, unit.lng]; + const zoom = Math.max(map.getZoom(), 13); + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduce) map.setView(target, zoom); + else map.flyTo(target, zoom, { duration: 0.55 }); + }, [map, unit]); + + return null; +} + +const TILES = { + light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', +} as const; + +export function MapPanel({ + units, + zone, + selectedId, + onSelect, +}: { + units: FleetUnit[]; + zone: RestrictedZone | null; + selectedId: string | null; + onSelect: (id: string) => void; +}) { + const scheme = useResolvedScheme(); + const selected = units.find((unit) => unit.id === selectedId) ?? null; + + return ( +
+
+

Map

+

+ Square ground · Circle drone · {zone?.name ?? 'Zone'} restricted +

+
+

Simulated positions on an OpenStreetMap basemap.

+
+ + + {zone ? ( + + {zone.name} · restricted airspace + + ) : null} + + + {units.map((unit) => ( + onSelect(unit.id) }} + > + + {unit.callsign} +
+ {kindLabel(unit.kind)} · {statusLabel(unit.status)} +
+ {unit.speedKph} km/h · {unit.batteryPct}% battery +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/SparkChart.test.tsx b/src/components/SparkChart.test.tsx new file mode 100644 index 0000000..d37dd3c --- /dev/null +++ b/src/components/SparkChart.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { SparkChart } from './SparkChart'; + +describe('SparkChart', () => { + it('draws a line and names the latest reading', () => { + const { container } = render( + , + ); + expect( + screen.getByRole('img', { name: /Speed in km\/h, Last 4 min, latest 15/ }), + ).toBeInTheDocument(); + expect(container.querySelector('.chart__line')?.getAttribute('d')).toMatch(/^M/); + expect(screen.getByText('20')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + }); + + it('explains a missing series', () => { + render( + , + ); + expect(screen.getByText('No samples')).toBeInTheDocument(); + }); +}); diff --git a/src/components/SparkChart.tsx b/src/components/SparkChart.tsx new file mode 100644 index 0000000..990cfa3 --- /dev/null +++ b/src/components/SparkChart.tsx @@ -0,0 +1,93 @@ +function domain(values: number[]): { lo: number; hi: number } { + const lo = Math.min(...values); + const hi = Math.max(...values); + if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi) { + return { lo: lo - 1, hi: hi + 1 }; + } + const pad = (hi - lo) * 0.12; + return { lo: lo - pad, hi: hi + pad }; +} + +function formatReading(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + +export function SparkChart({ + label, + unit, + values, + rangeLabel, + tone, +}: { + label: string; + unit: string; + values: number[]; + rangeLabel: string; + tone: 'speed' | 'battery' | 'altitude'; +}) { + const width = 320; + const height = 120; + const padX = 36; + const padTop = 12; + const padBottom = 18; + + if (values.length < 2) { + return ( +
+
+ + {label} ({unit}) + + — +
+

No samples

+
+ ); + } + + const { lo, hi } = domain(values); + const span = hi - lo || 1; + const latest = values[values.length - 1] ?? 0; + const dataLo = Math.min(...values); + const dataHi = Math.max(...values); + const coords = values.map((value, index) => { + const x = padX + (index / (values.length - 1)) * (width - padX - 8); + const y = padTop + (1 - (value - lo) / span) * (height - padTop - padBottom); + return [x, y] as const; + }); + const line = coords + .map(([x, y], index) => `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`) + .join(' '); + const last = coords[coords.length - 1] ?? [padX, height]; + const first = coords[0] ?? [padX, height]; + const baseline = height - padBottom; + const area = `${line} L${last[0].toFixed(1)} ${baseline} L${first[0].toFixed(1)} ${baseline} Z`; + + return ( +
+
+ + {label} ({unit}) + + + {formatReading(latest)} {unit} + +
+ + + {Math.round(dataHi)} + + + {Math.round(dataLo)} + + + + +

{rangeLabel}

+
+ ); +} diff --git a/src/components/TelemetryPanel.tsx b/src/components/TelemetryPanel.tsx new file mode 100644 index 0000000..ad153da --- /dev/null +++ b/src/components/TelemetryPanel.tsx @@ -0,0 +1,114 @@ +import { + formatCoord, + formatHeading, + historyWindowLabel, + kindLabel, + statusLabel, +} from '../format'; +import type { FleetUnit, TelemetrySample } from '../types'; +import { SparkChart } from './SparkChart'; + +function HeadingDial({ heading }: { heading: number }) { + return ( + + + + + ); +} + +export function TelemetryPanel({ + unit, + samples, + loading, +}: { + unit: FleetUnit | null; + samples: TelemetrySample[]; + loading: boolean; +}) { + const rangeLabel = historyWindowLabel(); + + return ( +
+
+

Telemetry

+

{unit ? unit.callsign : 'No selection'}

+
+ {!unit ? ( +

Select a unit to read speed, battery, and altitude.

+ ) : ( +
+
+
+

+ {unit.callsign} + + {statusLabel(unit.status)} + +

+

+ {kindLabel(unit.kind)} · {unit.routeName} · {unit.payload} +

+

+ {unit.desk} · {formatCoord(unit.lat)}, {formatCoord(unit.lng)} +

+
+ +
+
+
+
Speed
+
{unit.speedKph} km/h
+
+
+
Battery
+
+ {unit.batteryPct}% +
+
+
+
Altitude
+
{unit.altitudeM} m
+
+
+
Heading
+
{formatHeading(unit.heading)}
+
+
+ {loading && samples.length === 0 ? ( +

Loading history…

+ ) : ( +
+ sample.speedKph)} + /> + sample.batteryPct)} + /> + sample.altitudeM)} + /> +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/ThemeControl.test.tsx b/src/components/ThemeControl.test.tsx new file mode 100644 index 0000000..352ff7b --- /dev/null +++ b/src/components/ThemeControl.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { ThemeControl } from './ThemeControl'; + +describe('ThemeControl', () => { + it('pins the document to dark', async () => { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'color-scheme'); + meta.setAttribute('content', 'light dark'); + document.head.append(meta); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Dark' })); + expect(meta.getAttribute('content')).toBe('dark'); + expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(screen.getByRole('button', { name: /System, currently/ })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + meta.remove(); + }); +}); diff --git a/src/components/ThemeControl.tsx b/src/components/ThemeControl.tsx new file mode 100644 index 0000000..6093f97 --- /dev/null +++ b/src/components/ThemeControl.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { applyPreference, preferenceFromStorage, type SchemePreference } from '../theme'; +import { useSystemScheme } from '../useSystemScheme'; + +function storedPreference(): SchemePreference { + try { + return preferenceFromStorage(localStorage.getItem('color-scheme')); + } catch { + return 'system'; + } +} + +const OPTIONS: SchemePreference[] = ['system', 'light', 'dark']; + +function optionLabel(option: SchemePreference): string { + switch (option) { + case 'system': + return 'System'; + case 'light': + return 'Light'; + case 'dark': + return 'Dark'; + default: { + const unreachable: never = option; + return unreachable; + } + } +} + +export function ThemeControl() { + const system = useSystemScheme(); + const [preference, setPreference] = useState(storedPreference); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== 'color-scheme') return; + const next = preferenceFromStorage(event.newValue); + applyPreference(next); + setPreference(next); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const choose = (next: SchemePreference) => { + applyPreference(next); + setPreference(next); + }; + + return ( +
+ {OPTIONS.map((option) => ( + + ))} +
+ ); +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx new file mode 100644 index 0000000..03f10ed --- /dev/null +++ b/src/components/TopBar.tsx @@ -0,0 +1,77 @@ +import type { DataMode, FleetUnit } from '../types'; +import { Mark } from './icons'; +import { ThemeControl } from './ThemeControl'; + +function modeLabel(mode: DataMode): string { + switch (mode) { + case 'detecting': + return 'Linking'; + case 'live': + return 'Live socket'; + case 'browser': + return 'Browser sim'; + default: { + const unreachable: never = mode; + return unreachable; + } + } +} + +export function TopBar({ + mode, + linkNote, + units, + alertCount, +}: { + mode: DataMode; + linkNote: string; + units: FleetUnit[]; + alertCount: number; +}) { + const moving = units.filter((unit) => unit.speedKph > 1).length; + const averageBattery = + units.length === 0 + ? null + : Math.round(units.reduce((sum, unit) => sum + unit.batteryPct, 0) / units.length); + + return ( +
+ + Skip to fleet + +
+ +
+

Meridian

+

Fleet operations · portfolio demo

+
+
+
+
+
Units
+
{units.length || '—'}
+
+
+
Moving
+
{units.length ? moving : '—'}
+
+
+
Alerts
+
{units.length ? alertCount : '—'}
+
+
+
Avg battery
+
{averageBattery === null ? '—' : `${averageBattery}%`}
+
+
+
+

+

+ +
+
+ ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000..2478e81 --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,40 @@ +export function Mark() { + return ( + + ); +} + +export function GroundIcon() { + return ( + + ); +} + +export function DroneIcon() { + return ( + + ); +} diff --git a/src/data/browserEngine.ts b/src/data/browserEngine.ts new file mode 100644 index 0000000..4e0584e --- /dev/null +++ b/src/data/browserEngine.ts @@ -0,0 +1,4 @@ +import { createEngine } from '../sim/engine'; + +/** One simulator for the tab, used when the Node server is absent. */ +export const browserEngine = createEngine(); diff --git a/src/data/useFleetLink.ts b/src/data/useFleetLink.ts new file mode 100644 index 0000000..24d9734 --- /dev/null +++ b/src/data/useFleetLink.ts @@ -0,0 +1,101 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { isSnapshot } from '../guards'; +import { useUiStore } from '../store/ui'; +import type { FleetSnapshot } from '../types'; +import { browserEngine } from './browserEngine'; + +export const forceBrowser = import.meta.env.VITE_FORCE_BROWSER === 'true'; + +function socketUrl(): string { + if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL; + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${window.location.host}/ws`; +} + +export function useDataMode(): void { + const setLink = useUiStore((state) => state.setLink); + + useEffect(() => { + if (forceBrowser) { + setLink('browser', 'This build runs the simulator in the browser.'); + return; + } + + let active = true; + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 700); + fetch('/api/health', { signal: controller.signal }) + .then((response) => { + if (!active) return; + if (response.ok) { + setLink('live', 'Connected to the local simulator socket.'); + return; + } + setLink('browser', 'Simulator server did not respond. Running in this tab.'); + }) + .catch(() => { + if (!active) return; + setLink('browser', 'Simulator server is not reachable. Running in this tab.'); + }) + .finally(() => window.clearTimeout(timer)); + + return () => { + active = false; + window.clearTimeout(timer); + controller.abort(); + }; + }, [setLink]); +} + +export function useFleetStream(): void { + const mode = useUiStore((state) => state.dataMode); + const setLink = useUiStore((state) => state.setLink); + const queryClient = useQueryClient(); + + useEffect(() => { + if (mode === 'browser') { + const id = window.setInterval(() => { + queryClient.setQueryData(['fleet', 'browser'], browserEngine.snapshot()); + }, 1000); + return () => window.clearInterval(id); + } + + if (mode !== 'live') return; + + let stopped = false; + let socket: WebSocket | null = null; + let retries = 0; + let retryTimer = 0; + + const connect = () => { + socket = new WebSocket(socketUrl()); + socket.onmessage = (event) => { + try { + const payload: unknown = JSON.parse(String(event.data)); + if (!isSnapshot(payload)) return; + retries = 0; + queryClient.setQueryData(['fleet', 'live'], payload); + } catch { + // Ignore malformed frames. The next tick replaces them. + } + }; + socket.onclose = () => { + if (stopped) return; + retries += 1; + if (retries >= 2) { + setLink('browser', 'Live socket closed. Showing the in-browser simulator.'); + return; + } + retryTimer = window.setTimeout(connect, 400); + }; + }; + + connect(); + return () => { + stopped = true; + window.clearTimeout(retryTimer); + socket?.close(); + }; + }, [mode, queryClient, setLink]); +} diff --git a/src/filters.test.ts b/src/filters.test.ts new file mode 100644 index 0000000..d6e445c --- /dev/null +++ b/src/filters.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { filterUnits, sortUnits } from './filters'; +import type { FleetUnit } from './types'; + +function unit( + partial: Partial & Pick, +): FleetUnit { + return { + kind: 'ground', + lat: 0, + lng: 0, + heading: 0, + speedKph: 10, + batteryPct: 50, + altitudeM: 0, + payload: 'crates', + routeName: 'loop', + desk: 'desk', + link: 'nominal', + updatedAt: 0, + ...partial, + }; +} + +const units = [ + unit({ + id: 'a', + callsign: 'M-14', + status: 'enroute', + batteryPct: 10, + routeName: 'Embarcadero', + }), + unit({ + id: 'b', + callsign: 'D-12', + status: 'alert', + kind: 'drone', + batteryPct: 90, + payload: 'lidar', + }), + unit({ id: 'c', callsign: 'M-03', status: 'idle', batteryPct: 40, routeName: 'Yard' }), +]; + +describe('filters', () => { + it('matches callsign, route, and payload without case', () => { + expect( + filterUnits(units, { search: 'embar', kind: 'all', status: 'all' }).map((u) => u.id), + ).toEqual(['a']); + expect( + filterUnits(units, { search: 'LIDAR', kind: 'all', status: 'all' }).map((u) => u.id), + ).toEqual(['b']); + }); + + it('filters by kind and status', () => { + expect( + filterUnits(units, { search: '', kind: 'drone', status: 'all' }).map((u) => u.id), + ).toEqual(['b']); + expect( + filterUnits(units, { search: '', kind: 'all', status: 'idle' }).map((u) => u.id), + ).toEqual(['c']); + }); + + it('sorts alerts ahead of healthier units', () => { + expect(sortUnits(units).map((u) => u.callsign)).toEqual(['D-12', 'M-03', 'M-14']); + }); +}); diff --git a/src/filters.ts b/src/filters.ts new file mode 100644 index 0000000..7ee73d4 --- /dev/null +++ b/src/filters.ts @@ -0,0 +1,51 @@ +import type { FleetUnit, UnitKind, UnitStatus } from './types'; + +export interface FleetFilters { + search: string; + kind: 'all' | UnitKind; + status: 'all' | UnitStatus; +} + +export function filterUnits(units: FleetUnit[], filters: FleetFilters): FleetUnit[] { + const query = filters.search.trim().toLowerCase(); + return units.filter((unit) => { + if (filters.kind !== 'all' && unit.kind !== filters.kind) return false; + if (filters.status !== 'all' && unit.status !== filters.status) return false; + if (!query) return true; + return ( + unit.callsign.toLowerCase().includes(query) || + unit.routeName.toLowerCase().includes(query) || + unit.payload.toLowerCase().includes(query) + ); + }); +} + +function statusRank(status: UnitStatus): number { + switch (status) { + case 'alert': + return 0; + case 'charging': + return 1; + case 'idle': + return 2; + case 'enroute': + return 3; + default: { + const unreachable: never = status; + return unreachable; + } + } +} + +export function sortUnits(units: FleetUnit[]): FleetUnit[] { + return [...units].sort((a, b) => { + const byStatus = statusRank(a.status) - statusRank(b.status); + if (byStatus !== 0) return byStatus; + if (a.batteryPct !== b.batteryPct) return a.batteryPct - b.batteryPct; + return a.callsign.localeCompare(b.callsign); + }); +} + +export function filtersAreActive(filters: FleetFilters): boolean { + return filters.search.trim() !== '' || filters.kind !== 'all' || filters.status !== 'all'; +} diff --git a/src/format.test.ts b/src/format.test.ts new file mode 100644 index 0000000..4106eeb --- /dev/null +++ b/src/format.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { alertCodeLabel, formatAge, formatHeading, statusLabel } from './format'; + +describe('format', () => { + it('formats ages in short buckets', () => { + expect(formatAge(0, 0)).toBe('just now'); + expect(formatAge(0, 9_000)).toBe('just now'); + expect(formatAge(0, 12_000)).toBe('12s ago'); + expect(formatAge(0, 90_000)).toBe('1m ago'); + expect(formatAge(0, 7_200_000)).toBe('2h ago'); + }); + + it('pads headings and wraps negative degrees', () => { + expect(formatHeading(0)).toBe('000°'); + expect(formatHeading(90)).toBe('090°'); + expect(formatHeading(-10)).toBe('350°'); + expect(formatHeading(359.6)).toBe('000°'); + }); + + it('labels every status and alert code', () => { + expect(statusLabel('enroute')).toBe('En route'); + expect(alertCodeLabel('restricted-zone')).toBe('Airspace'); + }); +}); diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..6fd012f --- /dev/null +++ b/src/format.ts @@ -0,0 +1,85 @@ +import type { AlertCode, Severity, UnitKind, UnitStatus } from './types'; +import { HISTORY_POINTS, HISTORY_STEP_MS } from './sim/engine'; + +export function statusLabel(status: UnitStatus): string { + switch (status) { + case 'enroute': + return 'En route'; + case 'idle': + return 'Idle'; + case 'charging': + return 'Charging'; + case 'alert': + return 'Alert'; + default: { + const unreachable: never = status; + return unreachable; + } + } +} + +export function kindLabel(kind: UnitKind): string { + switch (kind) { + case 'ground': + return 'Ground'; + case 'drone': + return 'Drone'; + default: { + const unreachable: never = kind; + return unreachable; + } + } +} + +export function severityLabel(severity: Severity): string { + switch (severity) { + case 'warning': + return 'Warning'; + case 'critical': + return 'Critical'; + default: { + const unreachable: never = severity; + return unreachable; + } + } +} + +export function alertCodeLabel(code: AlertCode): string { + switch (code) { + case 'low-battery': + return 'Battery'; + case 'restricted-zone': + return 'Airspace'; + case 'link-degraded': + return 'Link'; + default: { + const unreachable: never = code; + return unreachable; + } + } +} + +export function formatAge(at: number, now: number): string { + const seconds = Math.max(0, Math.round((now - at) / 1000)); + if (seconds < 10) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +export function formatHeading(deg: number): string { + const normalized = ((deg % 360) + 360) % 360; + const rounded = Math.round(normalized) % 360; + return `${rounded.toString().padStart(3, '0')}°`; +} + +export function formatCoord(value: number): string { + return value.toFixed(5); +} + +export function historyWindowLabel(points = HISTORY_POINTS, stepMs = HISTORY_STEP_MS): string { + const minutes = Math.max(1, Math.round((points * stepMs) / 60_000)); + return `Last ${minutes} min`; +} diff --git a/src/guards.test.ts b/src/guards.test.ts new file mode 100644 index 0000000..701171e --- /dev/null +++ b/src/guards.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { isSnapshot } from './guards'; + +describe('isSnapshot', () => { + it('accepts a payload with units, alerts, time, and a zone', () => { + expect( + isSnapshot({ + units: [], + alerts: [], + serverTime: 1, + zone: { name: 'South Basin', south: 1, west: 2, north: 3, east: 4 }, + type: 'tick', + }), + ).toBe(true); + expect(isSnapshot({ units: [] })).toBe(false); + }); +}); diff --git a/src/guards.ts b/src/guards.ts new file mode 100644 index 0000000..18bc86c --- /dev/null +++ b/src/guards.ts @@ -0,0 +1,29 @@ +import type { FleetSnapshot, RestrictedZone, TelemetrySample } from './types'; + +export function isZone(value: unknown): value is RestrictedZone { + if (typeof value !== 'object' || value === null) return false; + const zone = value as Partial; + return ( + typeof zone.name === 'string' && + typeof zone.south === 'number' && + typeof zone.west === 'number' && + typeof zone.north === 'number' && + typeof zone.east === 'number' + ); +} + +export function isSnapshot(value: unknown): value is FleetSnapshot { + if (typeof value !== 'object' || value === null) return false; + const record = value as Partial; + return ( + Array.isArray(record.units) && + Array.isArray(record.alerts) && + typeof record.serverTime === 'number' && + isZone(record.zone) + ); +} + +export function isHistory(value: unknown): value is { samples: TelemetrySample[] } { + if (typeof value !== 'object' || value === null) return false; + return Array.isArray((value as { samples?: unknown }).samples); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..dad29ab --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,24 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import 'leaflet/dist/leaflet.css'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +const root = document.getElementById('root'); +if (!root) throw new Error('Missing #root'); + +// react-leaflet 4 throws "map container is already initialized" under StrictMode. +createRoot(root).render( + + + , +); diff --git a/src/sim/engine.test.ts b/src/sim/engine.test.ts new file mode 100644 index 0000000..3516534 --- /dev/null +++ b/src/sim/engine.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import type { FleetUnit } from '../types'; +import { HISTORY_POINTS, createEngine, deriveAlerts, projectUnit } from './engine'; +import { blueprintById, ROSTER } from './roster'; +import { inZone } from './geo'; +import { RESTRICTED_ZONE } from './roster'; + +const NOW = 1_700_000_000_000; + +function unit(partial: Partial & Pick): FleetUnit { + return { + callsign: partial.id.toUpperCase(), + status: 'enroute', + lat: 37.77, + lng: -122.42, + heading: 0, + speedKph: 30, + batteryPct: 80, + altitudeM: 0, + payload: 'payload', + routeName: 'route', + desk: 'desk', + link: 'nominal', + updatedAt: NOW, + ...partial, + }; +} + +describe('fleet engine', () => { + it('projects every roster unit into a finite position', () => { + const snapshot = createEngine().snapshot(NOW); + expect(snapshot.units).toHaveLength(ROSTER.length); + for (const fleetUnit of snapshot.units) { + expect(Number.isFinite(fleetUnit.lat)).toBe(true); + expect(Number.isFinite(fleetUnit.lng)).toBe(true); + expect(fleetUnit.batteryPct).toBeGreaterThanOrEqual(0); + expect(fleetUnit.batteryPct).toBeLessThanOrEqual(100); + } + }); + + it('keeps the yard mule parked and the degraded units in alert', () => { + const mule = blueprintById('m-03'); + const degraded = blueprintById('m-09'); + if (!mule || !degraded) throw new Error('missing blueprint'); + expect(projectUnit(mule, NOW).speedKph).toBe(0); + expect(projectUnit(mule, NOW).status).toBe('idle'); + expect(projectUnit(degraded, NOW).status).toBe('alert'); + expect(projectUnit(degraded, NOW).link).toBe('degraded'); + }); + + it('sends the basin drone through the restricted zone', () => { + const drone = blueprintById('d-15'); + if (!drone) throw new Error('missing drone'); + let hits = 0; + for (let t = NOW; t < NOW + 10 * 60_000; t += 2_000) { + const projected = projectUnit(drone, t); + if (inZone(projected.lat, projected.lng, RESTRICTED_ZONE)) hits += 1; + } + expect(hits).toBeGreaterThan(0); + }); + + it('raises link and battery alerts for a degraded unit and keeps them acknowledged', () => { + const engine = createEngine(); + const first = engine.snapshot(NOW); + const link = first.alerts.find((alert) => alert.id === 'link:m-09'); + const battery = first.alerts.find((alert) => alert.id === 'battery:d-02'); + expect(link?.severity).toBe('warning'); + expect(battery?.severity).toBe('critical'); + + engine.acknowledge('link:m-09'); + const second = engine.snapshot(NOW + 5_000); + expect(second.alerts.some((alert) => alert.id === 'link:m-09')).toBe(false); + expect(second.alerts.find((alert) => alert.id === 'link:m-09')).toBeUndefined(); + expect(second.alerts.find((alert) => alert.id === 'link:m-09')?.at).toBeUndefined(); + expect(link && second.alerts.find((alert) => alert.id === 'battery:d-02')?.at).toBe( + battery?.at, + ); + }); + + it('does not flag a ground vehicle inside the airspace box', () => { + const alerts = deriveAlerts( + [ + unit({ + id: 'truck', + kind: 'ground', + lat: 37.752, + lng: -122.39, + altitudeM: 0, + }), + ], + new Map(), + NOW, + ); + expect(alerts.map((alert) => alert.code)).not.toContain('restricted-zone'); + }); + + it('returns a time-ordered history window', () => { + const samples = createEngine().history('d-12', NOW, HISTORY_POINTS); + expect(samples).toHaveLength(HISTORY_POINTS); + expect(samples[0]?.t).toBeLessThan(samples[samples.length - 1]?.t ?? 0); + expect(samples[samples.length - 1]?.t).toBe(NOW); + }); +}); diff --git a/src/sim/engine.ts b/src/sim/engine.ts new file mode 100644 index 0000000..9cef3f1 --- /dev/null +++ b/src/sim/engine.ts @@ -0,0 +1,300 @@ +import type { Alert, FleetSnapshot, FleetUnit, TelemetrySample, UnitBlueprint } from '../types'; +import { buildRoute, inZone, pointAlong, type RouteGeom } from './geo'; +import { blueprintById, RESTRICTED_ZONE, ROSTER } from './roster'; + +export const HISTORY_POINTS = 48; +export const HISTORY_STEP_MS = 5_000; + +const routeCache = new Map(); + +function geometryFor(blueprint: UnitBlueprint): RouteGeom { + const cached = routeCache.get(blueprint.id); + if (cached) return cached; + const built = buildRoute(blueprint.route); + routeCache.set(blueprint.id, built); + return built; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function batteryAt(blueprint: UnitBlueprint, now: number): number { + switch (blueprint.behavior) { + case 'idle': { + const wobble = 0.5 + 0.5 * Math.sin(now / 20_000); + return 74 + Math.round(4 * wobble); + } + case 'charging': { + const phase = + ((now + blueprint.phaseMs) % blueprint.batteryPeriodMs) / blueprint.batteryPeriodMs; + return clamp(Math.round(22 + phase * 70), 0, 100); + } + case 'degraded': { + const wobble = 0.5 + 0.5 * Math.sin((now + blueprint.phaseMs) / 8_000); + return clamp(Math.round(6 + wobble * 5), 0, 100); + } + case 'patrol': { + const phase = + ((now + blueprint.phaseMs) % blueprint.batteryPeriodMs) / blueprint.batteryPeriodMs; + const wave = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2); + return clamp(Math.round(14 + wave * 84), 0, 100); + } + default: { + const unreachable: never = blueprint.behavior; + return unreachable; + } + } +} + +export function speedAt(blueprint: UnitBlueprint, now: number): number { + switch (blueprint.behavior) { + case 'idle': + case 'charging': + return 0; + case 'degraded': + case 'patrol': { + const factor = blueprint.behavior === 'degraded' ? 0.35 : 1; + const wave = 0.86 + 0.14 * Math.sin((now + blueprint.phaseMs) / 7_000); + return Math.round(blueprint.cruiseKph * factor * wave); + } + default: { + const unreachable: never = blueprint.behavior; + return unreachable; + } + } +} + +export function altitudeAt(blueprint: UnitBlueprint, now: number): number { + if (blueprint.kind === 'ground') return 0; + switch (blueprint.behavior) { + case 'idle': + case 'charging': + return 0; + case 'degraded': + case 'patrol': { + const wave = Math.sin((now + blueprint.phaseMs) / 9_000); + return Math.round(blueprint.altitudeBase + blueprint.altitudeAmp * wave); + } + default: { + const unreachable: never = blueprint.behavior; + return unreachable; + } + } +} + +export function projectUnit(blueprint: UnitBlueprint, now: number): FleetUnit { + const batteryPct = batteryAt(blueprint, now); + const home = blueprint.route[0] ?? { lat: 37.77, lng: -122.42 }; + let lat = home.lat; + let lng = home.lng; + let heading = 0; + + switch (blueprint.behavior) { + case 'idle': + case 'charging': + break; + case 'degraded': + case 'patrol': { + // Cruise speed keeps the loop smooth. The readout speed wobbles around it. + const factor = blueprint.behavior === 'degraded' ? 0.35 : 1; + const metersPerSecond = (blueprint.cruiseKph * factor * 1000) / 3600; + const distance = metersPerSecond * ((now + blueprint.phaseMs) / 1000); + const point = pointAlong(geometryFor(blueprint), distance); + lat = point.lat; + lng = point.lng; + heading = point.heading; + break; + } + default: { + const unreachable: never = blueprint.behavior; + throw new Error(`Unknown behavior ${String(unreachable)}`); + } + } + + let status: FleetUnit['status']; + switch (blueprint.behavior) { + case 'charging': + status = 'charging'; + break; + case 'idle': + status = batteryPct < 20 ? 'alert' : 'idle'; + break; + case 'degraded': + status = 'alert'; + break; + case 'patrol': + status = batteryPct < 20 ? 'alert' : 'enroute'; + break; + default: { + const unreachable: never = blueprint.behavior; + throw new Error(`Unknown behavior ${String(unreachable)}`); + } + } + + if ( + blueprint.kind === 'drone' && + blueprint.behavior !== 'charging' && + blueprint.behavior !== 'idle' && + inZone(lat, lng, RESTRICTED_ZONE) + ) { + status = 'alert'; + } + + return { + id: blueprint.id, + callsign: blueprint.callsign, + kind: blueprint.kind, + status, + lat, + lng, + heading, + speedKph: speedAt(blueprint, now), + batteryPct, + altitudeM: altitudeAt(blueprint, now), + payload: blueprint.payload, + routeName: blueprint.routeName, + desk: blueprint.desk, + link: blueprint.behavior === 'degraded' ? 'degraded' : 'nominal', + updatedAt: now, + }; +} + +export function historyFor( + blueprint: UnitBlueprint, + now: number, + points = HISTORY_POINTS, + stepMs = HISTORY_STEP_MS, +): TelemetrySample[] { + const samples: TelemetrySample[] = []; + for (let index = points - 1; index >= 0; index -= 1) { + const t = now - index * stepMs; + samples.push({ + t, + speedKph: speedAt(blueprint, t), + batteryPct: batteryAt(blueprint, t), + altitudeM: altitudeAt(blueprint, t), + }); + } + return samples; +} + +function pushAlert( + alerts: Alert[], + startedAt: Map, + alert: Omit, + now: number, +): void { + const existing = startedAt.get(alert.id); + const at = existing ?? now; + if (existing === undefined) startedAt.set(alert.id, at); + alerts.push({ ...alert, at }); +} + +export function deriveAlerts( + units: FleetUnit[], + startedAt: Map, + now: number, +): Alert[] { + const alerts: Alert[] = []; + const active = new Set(); + + for (const unit of units) { + if (unit.link === 'degraded') { + const id = `link:${unit.id}`; + active.add(id); + pushAlert( + alerts, + startedAt, + { + id, + unitId: unit.id, + callsign: unit.callsign, + severity: 'warning', + code: 'link-degraded', + message: `${unit.callsign} telemetry link is intermittent`, + }, + now, + ); + } + + if (unit.status !== 'charging' && unit.batteryPct < 20) { + const id = `battery:${unit.id}`; + active.add(id); + const severity = unit.batteryPct < 12 ? 'critical' : 'warning'; + pushAlert( + alerts, + startedAt, + { + id, + unitId: unit.id, + callsign: unit.callsign, + severity, + code: 'low-battery', + message: `${unit.callsign} battery at ${unit.batteryPct}%`, + }, + now, + ); + } + + if ( + unit.kind === 'drone' && + unit.altitudeM > 0 && + inZone(unit.lat, unit.lng, RESTRICTED_ZONE) + ) { + const id = `zone:${unit.id}`; + active.add(id); + pushAlert( + alerts, + startedAt, + { + id, + unitId: unit.id, + callsign: unit.callsign, + severity: 'critical', + code: 'restricted-zone', + message: `${unit.callsign} entered ${RESTRICTED_ZONE.name} restricted airspace`, + }, + now, + ); + } + } + + for (const id of startedAt.keys()) { + if (!active.has(id)) startedAt.delete(id); + } + + const severityRank: Record = { critical: 0, warning: 1 }; + return alerts.sort( + (a, b) => severityRank[a.severity] - severityRank[b.severity] || a.at - b.at, + ); +} + +export interface FleetEngine { + snapshot(now?: number): FleetSnapshot; + history(unitId: string, now?: number, points?: number): TelemetrySample[]; + acknowledge(id: string): void; +} + +export function createEngine(): FleetEngine { + const startedAt = new Map(); + const acknowledged = new Set(); + + return { + snapshot(now = Date.now()) { + const units = ROSTER.map((blueprint) => projectUnit(blueprint, now)); + const alerts = deriveAlerts(units, startedAt, now).filter( + (alert) => !acknowledged.has(alert.id), + ); + return { units, alerts, serverTime: now, zone: RESTRICTED_ZONE }; + }, + history(unitId, now = Date.now(), points = HISTORY_POINTS) { + const blueprint = blueprintById(unitId); + if (!blueprint) return []; + return historyFor(blueprint, now, points); + }, + acknowledge(id) { + acknowledged.add(id); + }, + }; +} diff --git a/src/sim/geo.test.ts b/src/sim/geo.test.ts new file mode 100644 index 0000000..0a8fc9d --- /dev/null +++ b/src/sim/geo.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { RESTRICTED_ZONE } from './roster'; +import { distanceMeters, headingDegrees, inZone } from './geo'; + +describe('geo', () => { + it('measures a zero distance for the same point', () => { + expect(distanceMeters({ lat: 37.7, lng: -122.4 }, { lat: 37.7, lng: -122.4 })).toBe(0); + }); + + it('heads east when longitude increases', () => { + const heading = headingDegrees({ lat: 37.7, lng: -122.4 }, { lat: 37.7, lng: -122.3 }); + expect(heading).toBeGreaterThan(80); + expect(heading).toBeLessThan(100); + }); + + it('treats the zone edges as inside', () => { + expect(inZone(RESTRICTED_ZONE.south, RESTRICTED_ZONE.west, RESTRICTED_ZONE)).toBe(true); + expect(inZone(RESTRICTED_ZONE.north + 0.01, RESTRICTED_ZONE.east, RESTRICTED_ZONE)).toBe( + false, + ); + }); +}); diff --git a/src/sim/geo.ts b/src/sim/geo.ts new file mode 100644 index 0000000..e926c82 --- /dev/null +++ b/src/sim/geo.ts @@ -0,0 +1,70 @@ +import type { LatLng, RestrictedZone } from '../types'; + +const METERS_PER_DEG_LAT = 111_320; + +export function distanceMeters(from: LatLng, to: LatLng): number { + const latRad = (from.lat * Math.PI) / 180; + const north = (to.lat - from.lat) * METERS_PER_DEG_LAT; + const east = (to.lng - from.lng) * METERS_PER_DEG_LAT * Math.cos(latRad); + return Math.hypot(east, north); +} + +/** Degrees clockwise from north. */ +export function headingDegrees(from: LatLng, to: LatLng): number { + const latRad = (from.lat * Math.PI) / 180; + const north = (to.lat - from.lat) * METERS_PER_DEG_LAT; + const east = (to.lng - from.lng) * METERS_PER_DEG_LAT * Math.cos(latRad); + const degrees = (Math.atan2(east, north) * 180) / Math.PI; + return (degrees + 360) % 360; +} + +export interface RouteGeom { + points: LatLng[]; + segmentLengths: number[]; + total: number; +} + +export function buildRoute(points: LatLng[]): RouteGeom { + const segmentLengths: number[] = []; + let total = 0; + for (let index = 0; index < points.length; index += 1) { + const start = points[index]; + const end = points[(index + 1) % points.length]; + if (!start || !end) continue; + const length = Math.max(distanceMeters(start, end), 1); + segmentLengths.push(length); + total += length; + } + return { points, segmentLengths, total: Math.max(total, 1) }; +} + +export function pointAlong( + route: RouteGeom, + distance: number, +): { lat: number; lng: number; heading: number } { + const first = route.points[0]; + if (!first) { + return { lat: 0, lng: 0, heading: 0 }; + } + let remaining = ((distance % route.total) + route.total) % route.total; + for (let index = 0; index < route.segmentLengths.length; index += 1) { + const length = route.segmentLengths[index] ?? 1; + const start = route.points[index]; + const end = route.points[(index + 1) % route.points.length]; + if (!start || !end) continue; + if (remaining <= length) { + const t = remaining / length; + return { + lat: start.lat + (end.lat - start.lat) * t, + lng: start.lng + (end.lng - start.lng) * t, + heading: headingDegrees(start, end), + }; + } + remaining -= length; + } + return { lat: first.lat, lng: first.lng, heading: 0 }; +} + +export function inZone(lat: number, lng: number, zone: RestrictedZone): boolean { + return lat >= zone.south && lat <= zone.north && lng >= zone.west && lng <= zone.east; +} diff --git a/src/sim/roster.ts b/src/sim/roster.ts new file mode 100644 index 0000000..458a57c --- /dev/null +++ b/src/sim/roster.ts @@ -0,0 +1,284 @@ +import type { LatLng, RestrictedZone, UnitBlueprint } from '../types'; + +const p = (lat: number, lng: number): LatLng => ({ lat, lng }); + +/** Fictional ops box on the Mission Bay waterfront. Ground traffic may pass under it. */ +export const RESTRICTED_ZONE: RestrictedZone = { + name: 'South Basin', + south: 37.746, + west: -122.398, + north: 37.758, + east: -122.382, +}; + +export const ROSTER: readonly UnitBlueprint[] = [ + { + id: 'm-14', + callsign: 'M-14', + kind: 'ground', + payload: 'Parcels · 18', + routeName: 'Embarcadero loop', + desk: 'North desk', + behavior: 'patrol', + cruiseKph: 36, + phaseMs: 12_000, + batteryPeriodMs: 140_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [ + p(37.7955, -122.4012), + p(37.8068, -122.4095), + p(37.8022, -122.4288), + p(37.7874, -122.4004), + ], + }, + { + id: 'm-22', + callsign: 'M-22', + kind: 'ground', + payload: 'Cold chain', + routeName: 'Bay Bridge freight', + desk: 'North desk', + behavior: 'patrol', + cruiseKph: 48, + phaseMs: 40_000, + batteryPeriodMs: 160_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [ + p(37.7888, -122.392), + p(37.8085, -122.364), + p(37.8224, -122.326), + p(37.8042, -122.338), + ], + }, + { + id: 'm-07', + callsign: 'M-07', + kind: 'ground', + payload: 'Spare parts', + routeName: 'Mission shuttle', + desk: 'City desk', + behavior: 'patrol', + cruiseKph: 32, + phaseMs: 8_000, + batteryPeriodMs: 130_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [ + p(37.7598, -122.4194), + p(37.7552, -122.441), + p(37.7694, -122.4468), + p(37.7796, -122.4142), + ], + }, + { + id: 'm-31', + callsign: 'M-31', + kind: 'ground', + payload: 'Depot transfer', + routeName: 'Potrero hauler', + desk: 'City desk', + behavior: 'patrol', + cruiseKph: 34, + phaseMs: 55_000, + batteryPeriodMs: 150_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [ + p(37.7604, -122.4055), + p(37.7482, -122.416), + p(37.7376, -122.4048), + p(37.7505, -122.3942), + ], + }, + { + id: 'm-18', + callsign: 'M-18', + kind: 'ground', + payload: 'Retail cages', + routeName: 'Sunset loop', + desk: 'West desk', + behavior: 'patrol', + cruiseKph: 38, + phaseMs: 22_000, + batteryPeriodMs: 145_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [ + p(37.7622, -122.4788), + p(37.7464, -122.4986), + p(37.7328, -122.4812), + p(37.751, -122.4634), + ], + }, + { + id: 'm-41', + callsign: 'M-41', + kind: 'ground', + payload: 'Seafood totes', + routeName: 'Wharf spur', + desk: 'North desk', + behavior: 'patrol', + cruiseKph: 28, + phaseMs: 70_000, + batteryPeriodMs: 125_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [ + p(37.8084, -122.4196), + p(37.8102, -122.4104), + p(37.8006, -122.4058), + p(37.7988, -122.4172), + ], + }, + { + id: 'm-03', + callsign: 'M-03', + kind: 'ground', + payload: 'Yard mule', + routeName: 'Mission Bay yard', + desk: 'Yard', + behavior: 'idle', + cruiseKph: 0, + phaseMs: 0, + batteryPeriodMs: 200_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [p(37.7688, -122.3955)], + }, + { + id: 'm-09', + callsign: 'M-09', + kind: 'ground', + payload: 'Medical stat', + routeName: 'Potrero climb', + desk: 'City desk', + behavior: 'degraded', + cruiseKph: 30, + phaseMs: 15_000, + batteryPeriodMs: 90_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [ + p(37.7572, -122.4118), + p(37.7614, -122.4036), + p(37.7546, -122.3994), + p(37.7518, -122.4132), + ], + }, + { + id: 'd-12', + callsign: 'D-12', + kind: 'drone', + payload: 'Survey lidar', + routeName: 'City survey', + desk: 'Air wing', + behavior: 'patrol', + cruiseKph: 54, + phaseMs: 5_000, + batteryPeriodMs: 120_000, + altitudeBase: 90, + altitudeAmp: 24, + route: [ + p(37.7852, -122.445), + p(37.8004, -122.4302), + p(37.7784, -122.4096), + p(37.7624, -122.4348), + ], + }, + { + id: 'd-04', + callsign: 'D-04', + kind: 'drone', + payload: 'Document pouch', + routeName: 'Downtown courier', + desk: 'Air wing', + behavior: 'patrol', + cruiseKph: 64, + phaseMs: 33_000, + batteryPeriodMs: 110_000, + altitudeBase: 70, + altitudeAmp: 16, + route: [ + p(37.7902, -122.4052), + p(37.7934, -122.4184), + p(37.7862, -122.4216), + p(37.7844, -122.4014), + ], + }, + { + id: 'd-19', + callsign: 'D-19', + kind: 'drone', + payload: 'Bridge camera', + routeName: 'Span inspector', + desk: 'Air wing', + behavior: 'patrol', + cruiseKph: 46, + phaseMs: 48_000, + batteryPeriodMs: 135_000, + altitudeBase: 120, + altitudeAmp: 18, + route: [ + p(37.7976, -122.3894), + p(37.8118, -122.3696), + p(37.8074, -122.3548), + p(37.7904, -122.3782), + ], + }, + { + id: 'd-08', + callsign: 'D-08', + kind: 'drone', + payload: 'Spare airframe', + routeName: 'Roof nest', + desk: 'Yard', + behavior: 'charging', + cruiseKph: 0, + phaseMs: 20_000, + batteryPeriodMs: 180_000, + altitudeBase: 0, + altitudeAmp: 0, + route: [p(37.7702, -122.3915)], + }, + { + id: 'd-02', + callsign: 'D-02', + kind: 'drone', + payload: 'Relay radio', + routeName: 'SoMa relay', + desk: 'Air wing', + behavior: 'degraded', + cruiseKph: 42, + phaseMs: 9_000, + batteryPeriodMs: 80_000, + altitudeBase: 46, + altitudeAmp: 8, + route: [ + p(37.7788, -122.4088), + p(37.7826, -122.3996), + p(37.7744, -122.3958), + p(37.7722, -122.4094), + ], + }, + { + id: 'd-15', + callsign: 'D-15', + kind: 'drone', + payload: 'Waterfront scan', + routeName: 'Basin transit', + desk: 'Air wing', + behavior: 'patrol', + cruiseKph: 58, + phaseMs: 2_000, + batteryPeriodMs: 150_000, + altitudeBase: 80, + altitudeAmp: 20, + route: [p(37.77, -122.41), p(37.752, -122.39), p(37.736, -122.388), p(37.742, -122.418)], + }, +]; + +export function blueprintById(id: string): UnitBlueprint | undefined { + return ROSTER.find((unit) => unit.id === id); +} diff --git a/src/store/ui.ts b/src/store/ui.ts new file mode 100644 index 0000000..32da3ea --- /dev/null +++ b/src/store/ui.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand'; +import type { DataMode, UnitKind, UnitStatus } from '../types'; + +interface UiState { + dataMode: DataMode; + linkNote: string; + selectedId: string | null; + search: string; + kind: 'all' | UnitKind; + status: 'all' | UnitStatus; + acknowledged: Record; + setLink: (mode: DataMode, linkNote: string) => void; + setSelectedId: (id: string | null) => void; + setSearch: (search: string) => void; + setKind: (kind: 'all' | UnitKind) => void; + setStatus: (status: 'all' | UnitStatus) => void; + acknowledge: (id: string) => void; + clearFilters: () => void; + reset: () => void; +} + +const initial = { + dataMode: 'detecting' as DataMode, + linkNote: 'Checking for a local simulator.', + selectedId: null as string | null, + search: '', + kind: 'all' as const, + status: 'all' as const, + acknowledged: {} as Record, +}; + +export const useUiStore = create((set) => ({ + ...initial, + setLink: (dataMode, linkNote) => set({ dataMode, linkNote }), + setSelectedId: (selectedId) => set({ selectedId }), + setSearch: (search) => set({ search }), + setKind: (kind) => set({ kind }), + setStatus: (status) => set({ status }), + acknowledge: (id) => + set((state) => ({ acknowledged: { ...state.acknowledged, [id]: true } })), + clearFilters: () => set({ search: '', kind: 'all', status: 'all' }), + reset: () => set({ ...initial, acknowledged: {} }), +})); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..7fc87a1 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,863 @@ +:root { + --canvas-light: oklch(96.4% 0.008 250); + --canvas-dark: oklch(17.5% 0.02 262); + --surface-light: oklch(99.2% 0.004 250); + --surface-dark: oklch(22.4% 0.024 262); + --ink-light: oklch(23% 0.028 262); + --ink-dark: oklch(95.5% 0.01 250); + --muted-light: oklch(46% 0.025 260); + --muted-dark: oklch(76% 0.02 255); + --line-light: oklch(88% 0.012 255); + --line-dark: oklch(33% 0.02 262); + --accent-light: oklch(46% 0.09 200); + --accent-dark: oklch(82% 0.09 200); + --amber-light: oklch(58% 0.13 72); + --amber-dark: oklch(82% 0.12 85); + --critical-light: oklch(50% 0.16 27); + --critical-dark: oklch(76% 0.14 25); + --ok-light: oklch(46% 0.1 158); + --ok-dark: oklch(80% 0.11 158); + --map-bg-light: oklch(92% 0.01 250); + --map-bg-dark: oklch(20% 0.02 262); + --scrollbar-thumb-light: oklch(72% 0.02 255); + --scrollbar-thumb-dark: oklch(42% 0.02 262); + --scrollbar-track-light: oklch(94% 0.006 250); + --scrollbar-track-dark: oklch(22% 0.02 262); + + --canvas: light-dark(var(--canvas-light), var(--canvas-dark)); + --surface: light-dark(var(--surface-light), var(--surface-dark)); + --ink: light-dark(var(--ink-light), var(--ink-dark)); + --muted: light-dark(var(--muted-light), var(--muted-dark)); + --line: light-dark(var(--line-light), var(--line-dark)); + --accent: light-dark(var(--accent-light), var(--accent-dark)); + --amber: light-dark(var(--amber-light), var(--amber-dark)); + --critical: light-dark(var(--critical-light), var(--critical-dark)); + --ok: light-dark(var(--ok-light), var(--ok-dark)); + --map-bg: light-dark(var(--map-bg-light), var(--map-bg-dark)); + --scrollbar-thumb: light-dark(var(--scrollbar-thumb-light), var(--scrollbar-thumb-dark)); + --scrollbar-track: light-dark(var(--scrollbar-track-light), var(--scrollbar-track-dark)); + --display: 'Fraunces', 'Iowan Old Style', Palatino, serif; + --sans: 'Outfit', 'Avenir Next', 'Segoe UI', sans-serif; + --mono: 'IBM Plex Mono', ui-monospace, monospace; + + color-scheme: light dark; + accent-color: var(--accent); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + color: var(--ink); + background: var(--canvas); + font-family: var(--sans); + font-size: 15px; + line-height: 1.45; +} + +:root:has(> head > meta[name='color-scheme'][content='dark']) { + color-scheme: dark; +} + +:root:has(> head > meta[name='color-scheme'][content='light']) { + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + background-color: var(--canvas); + background-image: radial-gradient(circle at 1px 1px, var(--line) 1px, transparent 0); + background-size: 22px 22px; +} + +button, +input { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; +} + +:focus { + outline: none; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +::selection { + background: color-mix(in oklch, var(--accent) 32%, transparent); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip { + position: absolute; + left: 12px; + top: -48px; + z-index: 5; + background: var(--ink); + color: var(--canvas); + padding: 8px 12px; + border-radius: 8px; +} + +.skip:focus { + top: 12px; +} + +.shell { + min-height: 100dvh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.top { + display: flex; + align-items: center; + gap: 20px; + padding: 14px 18px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 220px; +} + +.mark { + width: 38px; + height: 38px; + color: var(--accent); +} + +.brand h1 { + margin: 0; + font-family: var(--display); + font-size: 28px; + font-weight: 560; + letter-spacing: -0.03em; + line-height: 0.95; +} + +.brand p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; +} + +.kpis { + display: flex; + gap: 22px; + margin: 0 auto; +} + +.kpis div { + min-width: 72px; +} + +.kpis dt { + margin: 0; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.kpis dd { + margin: 2px 0 0; + font-family: var(--mono); + font-size: 18px; + font-variant-numeric: tabular-nums; +} + +.top__tools { + display: flex; + align-items: center; + gap: 10px; +} + +.conn { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: 12px; + white-space: nowrap; +} + +.conn__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} + +.conn[data-mode='live'] .conn__dot { + background: var(--ok); + animation: pulse 1.8s ease-out infinite; +} + +.conn[data-mode='browser'] .conn__dot { + background: var(--amber); +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in oklch, var(--ok) 55%, transparent); + } + 100% { + box-shadow: 0 0 0 8px transparent; + } +} + +.theme { + display: flex; + padding: 2px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--canvas); +} + +.theme button { + border: 0; + background: transparent; + color: var(--muted); + border-radius: 999px; + min-height: 32px; + padding: 6px 10px; + font-size: 12px; +} + +.theme button[aria-pressed='true'] { + background: var(--ink); + color: var(--canvas); +} + +.workspace { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) minmax(280px, 320px); + grid-template-rows: minmax(0, 1.4fr) minmax(240px, 0.85fr); + gap: 12px; + padding: 12px; + min-height: 0; +} + +.fleet { + grid-column: 1; + grid-row: 1 / span 2; +} + +.map { + grid-column: 2; + grid-row: 1; +} + +.telemetry { + grid-column: 2; + grid-row: 2; +} + +.alerts { + grid-column: 3; + grid-row: 1 / span 2; +} + +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 16px; +} + +.panel__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 12px 14px 0; +} + +.panel__head h2 { + margin: 0; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.panel__meta { + margin: 0; + color: var(--muted); + font-size: 12px; + text-align: right; +} + +.scroll { + min-height: 0; + overflow: auto; +} + +.filters { + display: grid; + gap: 8px; + padding: 12px 14px 8px; +} + +.search input { + width: 100%; + border: 1px solid var(--line); + background: var(--canvas); + border-radius: 10px; + padding: 9px 11px; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chip, +.text-button { + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + border-radius: 999px; + min-height: 32px; + padding: 4px 10px; + font-size: 12px; +} + +.chip[aria-pressed='true'] { + background: var(--ink); + color: var(--canvas); + border-color: var(--ink); +} + +.text-button { + justify-self: start; + color: var(--accent); + border-color: transparent; + padding-left: 0; +} + +.hint, +.empty, +.loading { + margin: 0; + padding: 8px 14px 16px; + color: var(--muted); +} + +.empty { + display: grid; + gap: 8px; + justify-items: start; +} + +.skeleton { + height: 62px; + margin: 8px 14px; + border-radius: 10px; + background: color-mix(in oklch, var(--line) 75%, transparent); +} + +.unit { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + text-align: left; + padding: 11px 14px; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; +} + +.unit:hover { + background: color-mix(in oklch, var(--ink) 4%, transparent); +} + +.unit.is-selected { + background: color-mix(in oklch, var(--accent) 12%, transparent); + box-shadow: inset 3px 0 0 var(--accent); +} + +.unit__icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 9px; + background: var(--canvas); + color: var(--accent); +} + +.icon { + width: 18px; + height: 18px; +} + +.unit__copy { + display: grid; + gap: 3px; + min-width: 0; +} + +.unit__title { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: baseline; +} + +.unit__call { + font-family: var(--mono); + font-size: 14px; +} + +.unit__meta { + margin: 0; + color: var(--muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.unit__stat { + display: grid; + justify-items: end; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-size: 14px; +} + +.unit__stat span:last-child, +.unit__pct { + color: var(--muted); + font-size: 11px; +} + +.meter-row { + display: flex; + align-items: center; + gap: 8px; +} + +.meter { + flex: 1; + height: 6px; + border-radius: 999px; + background: color-mix(in oklch, var(--line) 80%, transparent); + overflow: hidden; +} + +.meter > span { + display: block; + height: 100%; + background: var(--ok); +} + +.meter.is-low > span { + background: var(--amber); +} + +.meter.is-critical > span { + background: var(--critical); +} + +.status { + color: var(--muted); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +.status::before { + content: ''; + display: inline-block; + width: 7px; + height: 7px; + margin-right: 6px; + border-radius: 50%; + background: currentColor; +} + +.status--enroute { + color: var(--accent); +} + +.status--charging { + color: var(--amber); +} + +.status--alert { + color: var(--critical); +} + +.map-frame { + position: relative; + flex: 1; + min-height: 240px; + margin: 12px; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--line); +} + +.map-frame .leaflet-container { + position: absolute; + inset: 0; + height: 100%; + width: 100%; + background: var(--map-bg); + font-family: var(--sans); +} + +.leaflet-div-icon.pin-wrap { + background: transparent; + border: 0; +} + +.pin { + display: block; + width: 12px; + height: 12px; + margin: 5px; + border-radius: 3px; + background: var(--accent); + box-shadow: 0 0 0 2px var(--surface); +} + +.pin--drone { + border-radius: 50%; + background: var(--amber); +} + +.pin--alert { + background: var(--critical); +} + +.pin.is-selected { + transform: scale(1.45); + box-shadow: + 0 0 0 2px var(--surface), + 0 0 0 4px var(--ink); +} + +.leaflet-control-zoom.leaflet-bar, +.leaflet-control-zoom.leaflet-bar a { + background: var(--surface); + color: var(--ink); + border-color: var(--line); +} + +.leaflet-control-attribution { + background: color-mix(in oklch, var(--surface) 86%, transparent) !important; + color: var(--muted); + font-size: 10px; + max-width: 72%; +} + +.leaflet-control-attribution a { + color: var(--accent); +} + +.telemetry__body { + display: grid; + gap: 10px; + padding: 10px 12px 12px; +} + +.telemetry__identity { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.telemetry__name { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 4px; + font-family: var(--mono); + font-size: 16px; +} + +.dial { + width: 46px; + height: 46px; + flex: none; +} + +.dial circle { + fill: none; + stroke: var(--line); + stroke-width: 2; +} + +.dial path { + fill: var(--accent); +} + +.readouts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.readout { + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 12px; +} + +.readout dt { + margin: 0; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.readout dd { + margin: 4px 0 0; + font-family: var(--mono); + font-size: 18px; + font-variant-numeric: tabular-nums; +} + +.readout dd.is-critical { + color: var(--critical); +} + +.charts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.chart { + margin: 0; + min-width: 0; + --series: var(--accent); +} + +.chart--battery { + --series: var(--ok); +} + +.chart--altitude { + --series: var(--amber); +} + +.chart figcaption { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.chart figcaption strong { + color: var(--ink); + font-family: var(--mono); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.chart svg { + display: block; + width: 100%; + height: auto; +} + +.chart__line { + fill: none; + stroke: var(--series); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.chart__area { + fill: color-mix(in oklch, var(--series) 20%, transparent); + stroke: none; +} + +.chart__scale { + fill: var(--muted); + font-size: 10px; + font-family: var(--mono); +} + +.chart__range, +.chart__empty { + margin: 0; + color: var(--muted); + font-size: 11px; +} + +.alert-list { + list-style: none; + margin: 0; + padding: 4px 0 8px; +} + +.alert { + display: grid; + gap: 4px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); +} + +.alert__sev { + margin: 0; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.alert--critical .alert__sev { + color: var(--critical); +} + +.alert--warning .alert__sev { + color: var(--amber); +} + +.alert__msg, +.alert__meta { + margin: 0; +} + +.alert__meta { + color: var(--muted); + font-size: 12px; +} + +.alert .chip { + justify-self: start; + margin-top: 4px; +} + +@media (max-width: 1100px) { + .shell { + height: auto; + overflow: visible; + } + + .top { + flex-wrap: wrap; + } + + .kpis, + .top__tools { + width: 100%; + margin: 0; + justify-content: space-between; + } + + .workspace { + display: flex; + flex-direction: column; + height: auto; + } + + .map { + order: 1; + min-height: 480px; + } + + .fleet { + order: 2; + } + + .telemetry { + order: 3; + } + + .alerts { + order: 4; + } + + .fleet .scroll, + .alerts .scroll { + max-height: 480px; + } + + .charts, + .readouts { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 720px) { + .brand h1 { + font-size: 24px; + } + + .kpis { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 16px; + } + + .charts, + .readouts { + grid-template-columns: 1fr; + } + + .top__tools { + flex-wrap: wrap; + } +} + +@media (min-width: 1101px) { + .shell { + height: 100dvh; + overflow: hidden; + } +} + +@media (prefers-reduced-motion: reduce) { + .conn__dot { + animation: none; + } +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..a2fa6f3 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,24 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; +import { useUiStore } from '../store/ui'; + +if (typeof window !== 'undefined') { + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }) as MediaQueryList; +} + +afterEach(() => { + if (typeof document !== 'undefined') cleanup(); + useUiStore.getState().reset(); + if (typeof localStorage !== 'undefined') localStorage.clear(); +}); diff --git a/src/theme.test.ts b/src/theme.test.ts new file mode 100644 index 0000000..1a1d86c --- /dev/null +++ b/src/theme.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { applyPreference, metaContentFor, preferenceFromStorage, resolveScheme } from './theme'; + +describe('color scheme', () => { + it('maps the three preferences onto the meta tag', () => { + expect(metaContentFor('system')).toBe('light dark'); + expect(metaContentFor('dark')).toBe('dark'); + expect(preferenceFromStorage('nope')).toBe('system'); + expect(resolveScheme('system', 'dark')).toBe('dark'); + expect(resolveScheme('light', 'dark')).toBe('light'); + }); + + it('pins dark and clears the pin when returning to system', () => { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'color-scheme'); + meta.setAttribute('content', 'light dark'); + document.head.append(meta); + + applyPreference('dark'); + expect(meta.getAttribute('content')).toBe('dark'); + expect(localStorage.getItem('color-scheme')).toBe('dark'); + + applyPreference('system'); + expect(meta.getAttribute('content')).toBe('light dark'); + expect(localStorage.getItem('color-scheme')).toBeNull(); + meta.remove(); + }); +}); diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..bdbb4c2 --- /dev/null +++ b/src/theme.ts @@ -0,0 +1,61 @@ +export type SchemePreference = 'light' | 'dark' | 'system'; + +export function preferenceFromStorage(raw: string | null): SchemePreference { + if (raw === 'light' || raw === 'dark') return raw; + return 'system'; +} + +export function metaContentFor(preference: SchemePreference): 'light' | 'dark' | 'light dark' { + switch (preference) { + case 'light': + return 'light'; + case 'dark': + return 'dark'; + case 'system': + return 'light dark'; + default: { + const unreachable: never = preference; + return unreachable; + } + } +} + +export function resolveScheme( + preference: SchemePreference, + system: 'light' | 'dark', +): 'light' | 'dark' { + switch (preference) { + case 'system': + return system; + case 'light': + case 'dark': + return preference; + default: { + const unreachable: never = preference; + return unreachable; + } + } +} + +export function readMetaContent(): string { + return ( + document.querySelector('meta[name="color-scheme"]')?.getAttribute('content') ?? 'light dark' + ); +} + +export function preferenceFromMeta(content: string): SchemePreference { + if (content === 'light' || content === 'dark') return content; + return 'system'; +} + +/** Persist a deliberate light, dark, or system choice and reflect it on the root meta tag. */ +export function applyPreference(preference: SchemePreference): void { + const content = metaContentFor(preference); + document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', content); + try { + if (preference === 'system') localStorage.removeItem('color-scheme'); + else localStorage.setItem('color-scheme', preference); + } catch { + // Storage can throw when site data is blocked. The meta tag still updates this tab. + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f2a8935 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,82 @@ +export type UnitKind = 'ground' | 'drone'; + +export type UnitStatus = 'enroute' | 'idle' | 'charging' | 'alert'; + +export type Severity = 'warning' | 'critical'; + +export type AlertCode = 'low-battery' | 'restricted-zone' | 'link-degraded'; + +export type UnitBehavior = 'patrol' | 'idle' | 'charging' | 'degraded'; + +export type DataMode = 'detecting' | 'live' | 'browser'; + +export interface LatLng { + lat: number; + lng: number; +} + +export interface FleetUnit { + id: string; + callsign: string; + kind: UnitKind; + status: UnitStatus; + lat: number; + lng: number; + heading: number; + speedKph: number; + batteryPct: number; + altitudeM: number; + payload: string; + routeName: string; + desk: string; + link: 'nominal' | 'degraded'; + updatedAt: number; +} + +export interface Alert { + id: string; + unitId: string; + callsign: string; + severity: Severity; + code: AlertCode; + message: string; + at: number; +} + +export interface RestrictedZone { + name: string; + south: number; + west: number; + north: number; + east: number; +} + +export interface TelemetrySample { + t: number; + speedKph: number; + batteryPct: number; + altitudeM: number; +} + +export interface FleetSnapshot { + units: FleetUnit[]; + alerts: Alert[]; + serverTime: number; + zone: RestrictedZone; +} + +export interface UnitBlueprint { + id: string; + callsign: string; + kind: UnitKind; + payload: string; + routeName: string; + desk: string; + behavior: UnitBehavior; + cruiseKph: number; + phaseMs: number; + batteryPeriodMs: number; + altitudeBase: number; + altitudeAmp: number; + route: LatLng[]; +} diff --git a/src/useSystemScheme.ts b/src/useSystemScheme.ts new file mode 100644 index 0000000..41c1a81 --- /dev/null +++ b/src/useSystemScheme.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +function systemScheme(): 'light' | 'dark' { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +export function useSystemScheme(): 'light' | 'dark' { + const [scheme, setScheme] = useState<'light' | 'dark'>(systemScheme); + + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = () => setScheme(media.matches ? 'dark' : 'light'); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); + }, []); + + return scheme; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..521dd41 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_FORCE_BROWSER?: string; + readonly VITE_WS_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..153efe8 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..f6b4bff --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["vite.config.ts", "playwright.config.ts", "server", "e2e", "scripts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..30d98ea --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,20 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // Relative assets so the same build works on GitHub project pages and `vite preview`. + base: './', + plugins: [react()], + server: { + proxy: { + '/api': { target: 'http://127.0.0.1:8787', changeOrigin: true }, + '/ws': { target: 'ws://127.0.0.1:8787', ws: true }, + }, + }, + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: true, + exclude: ['e2e/**', 'node_modules/**', 'dist/**'], + }, +});