diff --git a/.github/workflows/release-gui.yml b/.github/workflows/release-gui.yml new file mode 100644 index 0000000..b2b0249 --- /dev/null +++ b/.github/workflows/release-gui.yml @@ -0,0 +1,57 @@ +name: GUI Release +on: + push: + pull_request: + branches: + - main +jobs: + Build-Action: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + - name: Install npm dependencies + run: npm ci + working-directory: ./sw/wulpus-frontend + - name: Build UI (react) + run: python build.py + working-directory: ./sw/wulpus-frontend + - name: Install python dependencies + working-directory: ./sw/wulpus + run: pip install -r requirements.txt + - name: Build .exe + working-directory: ./sw/wulpus + run: pyinstaller ./main.spec --noconfirm + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + dry_run: ${{ github.event_name == 'pull_request' || github.ref != 'refs/heads/main'}} + - name: Write version info to artifact + working-directory: ./sw/wulpus + shell: pwsh + run: | + $commitHash = (git rev-parse HEAD).Trim() + $commitDateFull = (git show -s --format=%cd --date=iso-strict HEAD).Trim() + $commitDate = $commitDateFull.Split('T')[0] + $text = "$commitHash from $commitDate (${{ steps.tag_version.outputs.new_tag }})" + $target = "./dist/main/_internal/wulpus/version.txt" + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null + Set-Content -Path $target -Value $text -Encoding UTF8 + - uses: actions/upload-artifact@v4 + id: artifact-upload-step + with: + name: wulpus-all-in-one + path: ./sw/wulpus/dist/main/ + compression-level: 8 + - name: Create release + uses: softprops/action-gh-release@v2 + if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main'}} + with: + tag_name: ${{ steps.tag_version.outputs.new_tag }} + name: UI-Release ${{ steps.tag_version.outputs.new_tag }} + body: Automated release of Wulpus GUI version ${{ steps.tag_version.outputs.new_tag }}. The release can be downloaded here [${{ steps.artifact-upload-step.outputs.artifact-url }}](${{ steps.artifact-upload-step.outputs.artifact-url }}) (github-login required) \ No newline at end of file diff --git a/.gitignore b/.gitignore index ee376b4..ec7712b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ .ipynb_checkpoints/ .vscode +*.clangd +sw/.venv +local-development/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..784c695 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# WULPUS Agent Guidelines + +## Commands + +- **Start backend**: `sw/.venv/Scripts/Activate.ps1` then `python -m wulpus.main` (hosts backend at http://127.0.0.1:8000/) +- **Frontend dev**: `npm run dev` in `sw/wulpus-frontend/` (dev server at http://localhost:5173/) +- **Frontend build**: `npm run build` in `sw/wulpus-frontend/` +- **Frontend lint**: `npm run lint` in `sw/wulpus-frontend/` + +## Architecture + +- **Firmware**: `fw/msp430/` (ultrasound MCU), `fw/nrf52/` (BLE MCU + USB dongle) +- **Software**: `sw/wulpus/` (FastAPI backend), `sw/wulpus-frontend/` (React frontend) +- **Communication**: WebSocket for real-time data, REST API for control + +## Code Style + +- **Python**: Type hints, snake_case, async/await for I/O +- **TypeScript/React**: camelCase, functional components, strict typing +- **Imports**: Standard library first, third-party, then local imports +- **File structure**: Clear separation of concerns, API models in separate files +- **Error handling**: Use proper exception types, HTTP status codes for API errors +- **Data processing**: NumPy for measurements, Pandas for structured data analysis + +### General +Write code where the naming of variables and functions is self explanatory. +Comments should be used sparingly. +Make sure to not catch error-cases that logically can't occur. \ No newline at end of file diff --git a/README.md b/README.md index ed75403..293d1b3 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Thanks to all the people who contributed to the WULPUS platform: - [Cédric Hirschi](https://www.linkedin.com/in/c%C3%A9dric-cyril-hirschi-09624021b/) (GUI improvements, Documentation) - [Josquin Tille](https://www.linkedin.com/in/josquin-tille-829a341a7/) (Silicone package design, Documentation) - [William Bruderer](https://www.linkedin.com/in/william-bruderer-59ba9b26b/) (Documentation) - +- [Louis Dod](https://github.com/13Bytes) (GUI Rework) # License The following files are released under Apache License 2.0 (`Apache-2.0`) (see `sw/LICENSE`): diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fc11f0a..00e6b43 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [1.3.0] - 2025-08-28 + +### Added +- New GUI with replay-functionality and extended logging, which is running in the browser +- This required a new API, which structures the way of communicating with the wulpus-server + +(More details can be found in the [sw-changelog](../sw/CHANGELOG.md)) + + ## [1.2.0] - 2025-01-27 ### Added diff --git a/docs/images/v1_2/Gui-Screenshot-2025-08-28.png b/docs/images/v1_2/Gui-Screenshot-2025-08-28.png new file mode 100644 index 0000000..d6944ac Binary files /dev/null and b/docs/images/v1_2/Gui-Screenshot-2025-08-28.png differ diff --git a/docs/images/v1_2/WULPUS-GUI Schema.excalidraw b/docs/images/v1_2/WULPUS-GUI Schema.excalidraw new file mode 100644 index 0000000..091b843 --- /dev/null +++ b/docs/images/v1_2/WULPUS-GUI Schema.excalidraw @@ -0,0 +1,789 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "jPdU1jb5kVGYWfPUXAc5O", + "type": "rectangle", + "x": 685.0299006426962, + "y": 125.91071428571422, + "width": 307.7800180377142, + "height": 508.11959271093707, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2v", + "roundness": { + "type": 3 + }, + "seed": 700920572, + "version": 529, + "versionNonce": 1417373252, + "isDeleted": false, + "boundElements": null, + "updated": 1756414553923, + "link": null, + "locked": false + }, + { + "id": "UHd-JASnTQjamf7Hgt_4T", + "type": "rectangle", + "x": 1230.7657789529137, + "y": 125.91071428571422, + "width": 307.7800180377142, + "height": 508.11959271093707, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2vV", + "roundness": { + "type": 3 + }, + "seed": 979099332, + "version": 632, + "versionNonce": 1456076412, + "isDeleted": false, + "boundElements": null, + "updated": 1756414625332, + "link": null, + "locked": false + }, + { + "id": "UAlcJpzVRJ4QUx1YtSybs", + "type": "rectangle", + "x": 216.48605596497595, + "y": 311, + "width": 257.99999999999994, + "height": 79.5, + "angle": 0, + "strokeColor": "#2f9e44", + "backgroundColor": "#ebfbee", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3H", + "roundness": { + "type": 3 + }, + "seed": 491181052, + "version": 199, + "versionNonce": 1810713284, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "5dvg4PSHdFq6vgjCHchhv" + }, + { + "id": "UYTEl3HNKB9YW1B2YPWTy", + "type": "arrow" + } + ], + "updated": 1756414593355, + "link": null, + "locked": false + }, + { + "id": "5dvg4PSHdFq6vgjCHchhv", + "type": "text", + "x": 304.38605749085485, + "y": 338.25, + "width": 82.19999694824219, + "height": 25, + "angle": 0, + "strokeColor": "#2f9e44", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3HV", + "roundness": null, + "seed": 1584157252, + "version": 155, + "versionNonce": 687821380, + "isDeleted": false, + "boundElements": null, + "updated": 1756414593355, + "link": null, + "locked": false, + "text": "WULPUS", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "UAlcJpzVRJ4QUx1YtSybs", + "originalText": "WULPUS", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "aicTJwpX3HBd6wX1U_set", + "type": "rectangle", + "x": 711.25, + "y": 204.875, + "width": 244, + "height": 301, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "#fff5f5", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3J", + "roundness": { + "type": 3 + }, + "seed": 244069572, + "version": 273, + "versionNonce": 1454411772, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "g7pc0dSzVyc2BTLmlVnSH" + }, + { + "id": "tGLmrIuHDjucy9RIeZgk0", + "type": "arrow" + }, + { + "id": "tP2Piw3sYenobd6tqpDke", + "type": "arrow" + }, + { + "id": "By8rkj2BBDVPz_GXWFfD2", + "type": "arrow" + }, + { + "id": "UYTEl3HNKB9YW1B2YPWTy", + "type": "arrow" + } + ], + "updated": 1756414588051, + "link": null, + "locked": false + }, + { + "id": "g7pc0dSzVyc2BTLmlVnSH", + "type": "text", + "x": 785.75, + "y": 330.375, + "width": 95, + "height": 50, + "angle": 0, + "strokeColor": "#e03131", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3JV", + "roundness": null, + "seed": 428156740, + "version": 226, + "versionNonce": 1344080124, + "isDeleted": false, + "boundElements": null, + "updated": 1756414475378, + "link": null, + "locked": false, + "text": "FastAPI\nwebserver", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "aicTJwpX3HBd6wX1U_set", + "originalText": "FastAPI\nwebserver", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "jPawIbEJ3ti-Sq1EQ-UYg", + "type": "rectangle", + "x": 1269.9444444444443, + "y": 198.5, + "width": 231.87499999999997, + "height": 308.125, + "angle": 0, + "strokeColor": "#f08c00", + "backgroundColor": "#fff4e6", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3U", + "roundness": { + "type": 3 + }, + "seed": 210590460, + "version": 217, + "versionNonce": 754984900, + "isDeleted": false, + "boundElements": [ + { + "id": "tP2Piw3sYenobd6tqpDke", + "type": "arrow" + }, + { + "id": "By8rkj2BBDVPz_GXWFfD2", + "type": "arrow" + }, + { + "id": "tGLmrIuHDjucy9RIeZgk0", + "type": "arrow" + }, + { + "type": "text", + "id": "S9DgnVr68FP88TdTMS0Ya" + } + ], + "updated": 1756414538207, + "link": null, + "locked": false + }, + { + "id": "S9DgnVr68FP88TdTMS0Ya", + "type": "text", + "x": 1341.6652772691514, + "y": 340.0625, + "width": 88.43333435058594, + "height": 25, + "angle": 0, + "strokeColor": "#f08c00", + "backgroundColor": "#fff4e6", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3V", + "roundness": null, + "seed": 290211324, + "version": 10, + "versionNonce": 559168252, + "isDeleted": false, + "boundElements": null, + "updated": 1756414541155, + "link": null, + "locked": false, + "text": "Frontend", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "jPawIbEJ3ti-Sq1EQ-UYg", + "originalText": "Frontend", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "IdcqPXT68DdIYN97wT6x-", + "type": "text", + "x": 690.7982175404507, + "y": 579.1270427635059, + "width": 288.77465759279767, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3Y", + "roundness": null, + "seed": 673199556, + "version": 146, + "versionNonce": 1342705660, + "isDeleted": false, + "boundElements": null, + "updated": 1756414553923, + "link": null, + "locked": false, + "text": "Python Application", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "top", + "containerId": null, + "originalText": "Python Application", + "autoResize": false, + "lineHeight": 1.25 + }, + { + "id": "5esIhToI6QZoLIqrqHKMV", + "type": "text", + "x": 1240.534095850668, + "y": 579.1270427635059, + "width": 288.77465759279767, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3YV", + "roundness": null, + "seed": 283729788, + "version": 250, + "versionNonce": 1421075836, + "isDeleted": false, + "boundElements": null, + "updated": 1756414562253, + "link": null, + "locked": false, + "text": "Browser", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "top", + "containerId": null, + "originalText": "Browser", + "autoResize": false, + "lineHeight": 1.25 + }, + { + "id": "tGLmrIuHDjucy9RIeZgk0", + "type": "arrow", + "x": 1260.471781201171, + "y": 230.0817419144514, + "width": 298.5279441365715, + "height": 112.76141768656214, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3Z", + "roundness": { + "type": 2 + }, + "seed": 1822283844, + "version": 1881, + "versionNonce": 2002214396, + "isDeleted": false, + "boundElements": [], + "updated": 1756414491315, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -298.5279441365715, + 58.967178284190425 + ], + [ + -7.285090710609666, + 112.76141768656214 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "jPawIbEJ3ti-Sq1EQ-UYg", + "focus": 0.9032954151386535, + "gap": 24.725245945377143 + }, + "endBinding": { + "elementId": "jPawIbEJ3ti-Sq1EQ-UYg", + "focus": -0.08681224372011292, + "gap": 16.75775395388291 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "tP2Piw3sYenobd6tqpDke", + "type": "arrow", + "x": 1249.4087301587304, + "y": 461.625, + "width": 285.21807343442003, + "height": 5.590534355697173, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3d", + "roundness": { + "type": 2 + }, + "seed": 1545679100, + "version": 286, + "versionNonce": 1716279236, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "F9HUJDxWUXFT91cm8B9I1" + } + ], + "updated": 1756414507739, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -118.97086111821886, + -2.775030077020915 + ], + [ + -285.21807343442003, + -5.590534355697173 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "jPawIbEJ3ti-Sq1EQ-UYg", + "focus": -0.7079107505070995, + "gap": 20.535714285713993 + }, + "endBinding": { + "elementId": "aicTJwpX3HBd6wX1U_set", + "focus": 0.6452407783098888, + "gap": 8.940656724310315 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "F9HUJDxWUXFT91cm8B9I1", + "type": "text", + "x": 1023.2039702884736, + "y": 448.05357142857144, + "width": 175.26666259765625, + "height": 50, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3e", + "roundness": null, + "seed": 242752580, + "version": 34, + "versionNonce": 445604860, + "isDeleted": false, + "boundElements": null, + "updated": 1756414452410, + "link": null, + "locked": false, + "text": "HTTP-Requests:\nControl-Commands", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "tP2Piw3sYenobd6tqpDke", + "originalText": "HTTP-Requests:\nControl-Commands", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "By8rkj2BBDVPz_GXWFfD2", + "type": "arrow", + "x": 1252.1146744607227, + "y": 383.33928571428567, + "width": 285.0446427033604, + "height": 5.684341886080802e-14, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3f", + "roundness": { + "type": 2 + }, + "seed": 1566373756, + "version": 266, + "versionNonce": 1638443388, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "U_MbWvQ6P8tlbdHpFXDuX" + } + ], + "updated": 1756414489731, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -285.0446427033604, + 5.684341886080802e-14 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "jPawIbEJ3ti-Sq1EQ-UYg", + "focus": -0.19976818313532244, + "gap": 17.82976998372169 + }, + "endBinding": { + "elementId": "aicTJwpX3HBd6wX1U_set", + "focus": 0.18580920740389203, + "gap": 11.820031757362244 + }, + "startArrowhead": "arrow", + "endArrowhead": null, + "elbowed": false + }, + { + "id": "U_MbWvQ6P8tlbdHpFXDuX", + "type": "text", + "x": 1058.0920645093163, + "y": 358.3392857142857, + "width": 107.63333129882812, + "height": 50, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3g", + "roundness": null, + "seed": 1823297092, + "version": 97, + "versionNonce": 796492924, + "isDeleted": false, + "boundElements": null, + "updated": 1756414452410, + "link": null, + "locked": false, + "text": "Websocket:\nLive Data", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "By8rkj2BBDVPz_GXWFfD2", + "originalText": "Websocket:\nLive Data", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "aD8wO18I7_fn9Tmirltaj", + "type": "text", + "x": 1000.0010034476036, + "y": 276.7335078889831, + "width": 258.73333740234375, + "height": 25, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3k", + "roundness": null, + "seed": 121111036, + "version": 430, + "versionNonce": 1110184260, + "isDeleted": false, + "boundElements": [ + { + "id": "tGLmrIuHDjucy9RIeZgk0", + "type": "arrow" + } + ], + "updated": 1756414452410, + "link": null, + "locked": false, + "text": "HTTP Request of website", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "HTTP Request of website", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "UYTEl3HNKB9YW1B2YPWTy", + "type": "arrow", + "x": 484.4205657155102, + "y": 349.5239647449018, + "width": 219.8943513240872, + "height": 1.8635114518990576, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "#fff4e6", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3n", + "roundness": { + "type": 2 + }, + "seed": 1712324164, + "version": 123, + "versionNonce": 1924452860, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "qibzpqX5KROlu4MDgqob6" + } + ], + "updated": 1756414603883, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 219.8943513240872, + 1.8635114518990576 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "UAlcJpzVRJ4QUx1YtSybs", + "focus": -0.03084365421630684, + "gap": 9.93450975053429 + }, + "endBinding": { + "elementId": "aicTJwpX3HBd6wX1U_set", + "focus": 0.019103670142447363, + "gap": 6.93508296040261 + }, + "startArrowhead": "arrow", + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "qibzpqX5KROlu4MDgqob6", + "type": "text", + "x": 538.6670574147562, + "y": 337.02396474490183, + "width": 131.89999389648438, + "height": 25, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "#fff4e6", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b3o", + "roundness": null, + "seed": 1835258876, + "version": 4, + "versionNonce": 1563843524, + "isDeleted": false, + "boundElements": null, + "updated": 1756414603883, + "link": null, + "locked": false, + "text": "Bluetooth LE", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "UYTEl3HNKB9YW1B2YPWTy", + "originalText": "Bluetooth LE", + "autoResize": true, + "lineHeight": 1.25 + } + ], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff", + "lockedMultiSelections": {} + }, + "files": {} +} \ No newline at end of file diff --git a/docs/images/v1_2/WULPUS-GUI Schema.png b/docs/images/v1_2/WULPUS-GUI Schema.png new file mode 100644 index 0000000..314c506 Binary files /dev/null and b/docs/images/v1_2/WULPUS-GUI Schema.png differ diff --git a/fw/msp430/wulpus_msp430_firmware/main.c b/fw/msp430/wulpus_msp430_firmware/main.c index b558a05..d0bb57c 100644 --- a/fw/msp430/wulpus_msp430_firmware/main.c +++ b/fw/msp430/wulpus_msp430_firmware/main.c @@ -45,6 +45,7 @@ static void getConfigPack(void); static void configAfterPowerUp(void); static void receiveUssConfPackage(void); static void usAcquisitionLoop(void); +static void prepareUSSAcquisition(); // Callbacks implementation static void hsPllUnlockCallback(void); @@ -52,6 +53,7 @@ static void saphSeqAcqDoneCallback(void); static void slowTimerCc2Callback(void); static void fastTimerCc0Callback(void); + int main(void) { @@ -69,27 +71,20 @@ int main(void) tx_rx_id = 0; meas_frame_nr = 0; - // Receive Uss configuration package from nRF - receiveUssConfPackage(); - - // Configure Uss according to the new package - confUsSubsystem(); + // Power down HV PCB to save energy + disableHvPcbDcDc(); + disableOpAmpSupply(); + disableHvPcbSupply(); - // Configure the events of slow and fast timers - confTimerSlowSwEvents(); - confTimerFastSwEvents(); - - // Power up HV PCB - enableHvPcbSupply(); - // Enable Power for OPA836 - enableOpAmpSupply(); + // Wait until we receive Uss configuration package from nRF + receiveUssConfPackage(); - // Enter acquisition loop + prepareUSSAcquisition(); + // Do measurements usAcquisitionLoop(); - - } + } + // Not reachable - return 0; } @@ -147,7 +142,6 @@ void configAfterPowerUp(void) } static void receiveUssConfPackage(void) - { while(1) { @@ -171,6 +165,22 @@ static void receiveUssConfPackage(void) } } +static void prepareUSSAcquisition(){ + enableHvPcbDcDc(); + + // Configure Uss according to the new package + confUsSubsystem(); + + // Configure the events of slow and fast timers + confTimerSlowSwEvents(); + confTimerFastSwEvents(); + + // Power up HV PCB + enableHvPcbSupply(); + // Enable Power for OPA836 + enableOpAmpSupply(); +} + static void usAcquisitionLoop(void) { @@ -270,9 +280,9 @@ static void saphSeqAcqDoneCallback(void) // and OpAmp // Disable HV and +5 V - disableHvPcbDcDc(); + // disableHvPcbDcDc(); // Disable RX OPA836 - disableOpAmp(); + // disableOpAmp(); // if PLL unlock event occurs earlier the acquisition might be not valid if (isEventFlagSet(HS_PLL_UNLOCK_EVENT) == false) @@ -298,7 +308,7 @@ static void fastTimerCc0Callback(void) // Switch HV Mux hvMuxLatchOutput(); // Disable HV DC-DC (we don't need V at this point) - disableHvDcDc(); + // disableHvDcDc(); // Disable Fast Timer timerFastStop(); } diff --git a/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.c b/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.c index ed11759..e6607c2 100644 --- a/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.c +++ b/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.c @@ -57,8 +57,8 @@ void getDefaultUsConfig(msp_config_t * msp_config) // TX/RX configurations msp_config->txRxConfLen = 0; -// msp_config->txConfigs[TX_RX_CONF_LEN_MAX]; -// msp_config->rxConfigs[TX_RX_CONF_LEN_MAX]; + // msp_config->txConfigs[TX_RX_CONF_LEN_MAX]; + // msp_config->rxConfigs[TX_RX_CONF_LEN_MAX]; // Pulser settins msp_config->driveStrength = PPG_NORMAL_DRIVE; @@ -70,8 +70,6 @@ void getDefaultUsConfig(msp_config_t * msp_config) msp_config->numStopPulses = 0; msp_config->pulserPolarity = PPG_POLARITY_START_WITH_HIGH; msp_config->pulserPauseState = PPG_PAUSE_STATE_LOW; - - return; } // Extract Uss config from spi RX buffer @@ -79,8 +77,9 @@ void getDefaultUsConfig(msp_config_t * msp_config) bool extractUsConfig(uint8_t * spi_rx, msp_config_t * msp_config) { // Check start byte - if (spi_rx[0] != START_BYTE_CONF_PACK) + if(!isNewConfigCondition(spi_rx)){ return 0; + } // Note: The MSP430 cannot access 2-byte words at odd addresses, so the CPU just ignores the lowest bit of word addresses. //Therefore, we do here some magic @@ -120,16 +119,15 @@ bool extractUsConfig(uint8_t * spi_rx, msp_config_t * msp_config) return 1; } -// Check the first byte and check if restart should be done. +// Check the first byte if restart should be performed bool isRestartCondition(uint8_t * spi_rx) { - // Check for restart byte - if (spi_rx[0] != START_BYTE_RESTART) - { - return 0; - } + return (spi_rx[0] == START_BYTE_RESTART); +} - return 1; +// Check the first byte if a new config is requested +bool isNewConfigCondition(uint8_t * spi_rx){ + return (spi_rx[0] == START_BYTE_CONF_PACK); } // Initiate MSP430-controlled power switches @@ -149,8 +147,6 @@ void initAllPowerSwitches(void) // Set Pin 4 to output "SW_EN" (Switch DC/DC: TPS61222) // Set Pin 5 to output "HV_EN" (HV DC/DC: LT1945) GPIO_setAsOutputPin(GPIO_PORT_P6, GPIO_PIN0 + GPIO_PIN4 + GPIO_PIN5); - - return; } // Init other GPIOs @@ -162,8 +158,6 @@ void initOtherGpios(void) // Configure LED P1DIR |= GPIO_PIN_LED_MSP430; P1OUT &= ~GPIO_PIN_LED_MSP430; - - return; } bool isBleReady(void) @@ -193,8 +187,6 @@ void enableOpAmp(void) // Enable RX OPA836 // Set Pin 0 "RxEn" to high GPIO_setOutputHighOnPin(GPIO_PORT_P6, GPIO_PIN0); - - return; } // Disable Rx Operational Amplifier @@ -203,8 +195,6 @@ void disableOpAmp(void) // Disable RX OPA836 // Set Pin 0 "RxEn" to low GPIO_setOutputLowOnPin(GPIO_PORT_P6, GPIO_PIN0); - - return; } // Enable HV PCB power supply @@ -215,8 +205,6 @@ void enableHvPcbSupply(void) // just for the US measurements // Set Pin 2 "EN HV" to high (power up HV PCB) GPIO_setOutputHighOnPin(GPIO_PORT_P2, GPIO_PIN2); - - return; } // Disable HV PCB power supply @@ -234,8 +222,6 @@ void enableHvPcbDcDc(void) // Set Pin 4 "SW_EN" to high (enables the DC/DC TPS61222) // Set Pin 5 "HV1_EN" to high (enables the HV DC/DC LT1945) GPIO_setOutputHighOnPin(GPIO_PORT_P6, GPIO_PIN4+GPIO_PIN5); - - return; } // Disable DC-DC converters on HV PCB @@ -245,8 +231,6 @@ void disableHvPcbDcDc(void) // Set Pin 4 "SW_EN" to low (disables the DC/DC TPS61222) // Set Pin 5 "HV1_EN" to low (disables the HV DC/DC LT1945) GPIO_setOutputLowOnPin(GPIO_PORT_P6, GPIO_PIN4 + GPIO_PIN5); - - return; } // Disable only HV DC-DC converter on HV PCB diff --git a/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.h b/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.h index 7c53a8c..a906831 100644 --- a/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.h +++ b/fw/msp430/wulpus_msp430_firmware/wulpus/wulpus_sys.h @@ -59,8 +59,10 @@ bool extractUsConfig(uint8_t * spi_rx, msp_config_t * msp_config); //// Extra functions //// -// Check the first byte and check if restart should be performed +// Check the first byte if restart should be performed bool isRestartCondition(uint8_t * spi_rx); +// Check the first byte if a new config is requested +bool isNewConfigCondition(uint8_t * spi_rx); // Initiate MSP430-controlled power switches void initAllPowerSwitches(void); diff --git a/sw/CHANGELOG.md b/sw/CHANGELOG.md index 1a9bb8b..9ac795b 100644 --- a/sw/CHANGELOG.md +++ b/sw/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2025-08-28 + +### Added + +- A new web-based UI application (`wulpus-frontend/`, Vite + React) with panels for connection, TX/RX config, ultrasound config, logs, and live graphing. + It has feature-pairity with the jupyter notebook gui. +- A new FastAPI backend service (`wulpus/`) exposing HTTP APIs and a WebSocket stream for live measurements; includes config and log endpoints. +- Recording functionality and compressed logging that includes the recording start timestamp. +- Mocked dongle, simulation, and replay modes to ease development and demonstrations. +- User-defined graph filter, fullscreen mode for the graph +- Configuration persistence across reloads via localStorage. + +### Changed + +- Moved the existing Gui from `/sw/wulpus` into `/sw/jupyter notebook (legacy)/wulpus_jptnbk`. +- Frontend is now served by the FastAPI backend. +- Refactored US acquisition setup; added capability to live-patch configuration during runtime. +- Reworked and typed the `latest_frame` data structure, enabling reliable B-mode visualization. + + ## [1.1.0] - 2024-02-21 ### Added diff --git a/sw/HOW_TO_INSTALL.md b/sw/HOW_TO_INSTALL.md new file mode 100644 index 0000000..7dc52f8 --- /dev/null +++ b/sw/HOW_TO_INSTALL.md @@ -0,0 +1,31 @@ +# How to install Python requirements? + +1. Install Anaconda package manager
+ https://docs.conda.io/en/latest/miniconda.html +2. Find `requirements.yaml` file in this folder +3. Open terminal (Windows: Anaconda Prompt) in this folder. +4. Execute the following command to create environment: + +``` + conda env create -f requirements.yml +``` + +# How to install Node.js and requirements? + +1. Install current version of Node.js
+ https://nodejs.org/en/#home-downloadhead +2. Open Terminal in `\sw\wulpus-frontend` +3. Execute the following command to install all npm dependencies: + +``` + npm i +``` + +### Initial build + +- Open Terminal in `\sw\wulpus-frontend` +- Execute the following command to build the frontend and copy the results into the right folder: + +``` + python build.py +``` diff --git a/sw/LICENSE b/sw/LICENSE index 34e778a..3b72927 100644 --- a/sw/LICENSE +++ b/sw/LICENSE @@ -1,5 +1,3 @@ -Copyright (C) 2023 ETH Zurich. All rights reserved. - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/sw/MATLAB_load_wulpus_log.m b/sw/MATLAB_load_wulpus_log.m new file mode 100644 index 0000000..5502dc8 --- /dev/null +++ b/sw/MATLAB_load_wulpus_log.m @@ -0,0 +1,171 @@ +%% WULPUS Data Loading Script + +%% Configuration +% Paths to measurement ZIP files +% paths needs to be a list of all zips, that should get concatinated. +% ´get_all_zips_from_folder´ can help + +% paths = {'.\\wulpus\\measurements\\wulpus-2025-09-05_11-01-36.zip'}; +paths = get_all_zips_from_folder('.\logs'); +SAMPLE_CROP = 99999; % Set to large number (e.g., 99999) for no crop + +%% Load Data +[wulpus_data, config] = zip_to_dataframe(paths{1}); + +if length(paths) > 1 + fprintf('Multiple (%d) paths detected, concatenating DataFrames\n', length(paths)); + for i = 2:length(paths) + [df_new, config_new] = zip_to_dataframe(paths{i}); + wulpus_data = concat_dataframes(wulpus_data, df_new); + config = config_new; + clear df_new config_new; + end + clear i; +end + +fprintf('Loaded %d measurements\n', height(wulpus_data)); +disp(wulpus_data(1:min(5, height(wulpus_data)), :)); + + +%% Helper Functions + +function paths = get_all_zips_from_folder(folder) +% Get all ZIP files from folder +files = dir(fullfile(folder, '*.zip')); +paths = arrayfun(@(f) fullfile(f.folder, f.name), files, 'UniformOutput', false); +end + +function [df, config] = zip_to_dataframe(zip_path) +% Load data from ZIP archive containing Parquet file +% Extract to temporary directory +temp_dir = tempname; +unzip(zip_path, temp_dir); + +% Load Parquet file (data.parquet) +parquet_path = fullfile(temp_dir, 'data.parquet'); +if ~isfile(parquet_path) + error('No data.parquet file found in ZIP archive'); +end + +% Read Parquet file (requires MATLAB R2019a+ or parquetread function) +try + df_flat = parquetread(parquet_path); +catch + error('Failed to read Parquet file. Ensure MATLAB R2019a+ or parquetread is available.'); +end + +% Identify meta columns vs sample columns +meta_cols = {'tx', 'rx', 'aq_number', 'tx_rx_id', 'log_version'}; +all_cols = df_flat.Properties.VariableNames; +sample_cols = setdiff(all_cols, meta_cols, 'stable'); + +% Sort sample columns numerically (they're stored as strings like '0', '1', '2'...) +sample_nums = cellfun(@str2double, sample_cols); +[~, sort_idx] = sort(sample_nums); +sample_cols = sample_cols(sort_idx); + +% Rebuild measurement column as cell array of vectors +n_rows = height(df_flat); +measurement_cell = cell(n_rows, 1); +for i = 1:n_rows + measurement_cell{i} = table2array(df_flat(i, sample_cols))'; +end + +% Create output table +df = table(); +df.measurement = measurement_cell; +df.tx = df_flat.tx; +df.rx = df_flat.rx; +df.aq_number = df_flat.aq_number; + +if ismember('tx_rx_id', all_cols) + df.tx_rx_id = df_flat.tx_rx_id; +else + df.tx_rx_id = (0:n_rows-1)'; +end + +if ismember('log_version', all_cols) + df.log_version = df_flat.log_version; +else + df.log_version = ones(n_rows, 1); +end + +% Convert index to datetime if available +try + t = df_flat.x__index_level_0__; + t = t / 1e6; % µs -> Sekunden + df.time = datetime(t, 'ConvertFrom','posixtime', 'TimeZone','America/Vancouver'); +catch + df.time = (1:n_rows)'; +end + + +% Load config JSON (config-0.json) +json_files = dir(fullfile(temp_dir, 'config-*.json')); +if ~isempty(json_files) + json_path = fullfile(json_files(1).folder, json_files(1).name); + config = jsondecode(fileread(json_path)); +else + config = struct(); +end + +% Cleanup +rmdir(temp_dir, 's'); +end + +function df_concat = concat_dataframes(df1, df2) +% Concatenate two dataframes +df_concat = [df1; df2]; +end + +function [flat_df, meas_cols] = flatten_df_measurements(df, sample_crop) +% Flatten measurement arrays into separate columns +n_rows = height(df); + +if n_rows == 0 + flat_df = table(); + meas_cols = {}; + return; +end + +% Get measurement dimensions +first_meas = df.measurement{1}; +n_samples = min(length(first_meas), sample_crop); + +% Pre-allocate matrix +meas_matrix = zeros(n_rows, n_samples); + +for i = 1:n_rows + meas = df.measurement{i}; + meas_matrix(i, :) = meas(1:n_samples); +end + +% Create column names +meas_cols = arrayfun(@(x) sprintf('sample_%d', x-1), 1:n_samples, 'UniformOutput', false); + +% Create flattened table +flat_df = [df(:, ~strcmp(df.Properties.VariableNames, 'measurement')), ... + array2table(meas_matrix, 'VariableNames', meas_cols)]; +end + +function imshow_with_time_labels(timestamps) +% Set x-axis labels based on timestamps +n_acq = length(timestamps); +num_ticks = min(10, n_acq); + +if n_acq > 0 + tick_positions = round(linspace(1, n_acq, num_ticks)); + xticks(tick_positions); + + % Format timestamps + time_labels = cell(1, num_ticks); + for i = 1:num_ticks + idx = tick_positions(i); + % Convert Unix timestamp to datetime + dt = datetime(timestamps(idx), 'ConvertFrom', 'posixtime', 'Format', 'HH:mm:ss'); + time_labels{i} = char(dt); + end + xticklabels(time_labels); + xtickangle(45); +end +end diff --git a/sw/README.md b/sw/README.md index 3095237..89b7c60 100644 --- a/sw/README.md +++ b/sw/README.md @@ -1,9 +1,86 @@ # WULPUS GUI source files -This directory contains the source files for WULPUS Graphical User Interface (`sw/wulpus`) and an example Jupyter notebook (`sw/wulpus_gui.ipynb`). +This directory contains the source files for WULPUS Graphical User Interface and an example Jupyter notebook ([wulpus_gui.ipynb](./jupyter%20notebook%20(legacy)/wulpus_gui.ipynb)). # How to get started? +Follow [HOW_TO_INSTALL](./HOW_TO_INSTALL.md) to install the dependencies. +The jupyter notebook requires only the python dependencies, while the web-based GUI requires also the Node.js-dependencies. -Follow `sw/how_to_install_dependencies.md` to install Python dependencies and launch an example Jupyter notebook. +WULPUS GUI Screenshot + + +### User-interface +How to Launch the user-interface: + +- After completing the initial build, open a new terminal and navigate into `/sw` +- Run the command to laod your anaconda environment: +``` + conda activate wulpus_env +``` +- To start the system, execute +``` + python -m wulpus.main +``` +You should be able to open a browser of you choice and visit [http://127.0.0.1:8000/](http://127.0.0.1:8000/) + + +#### Using the log-file +The log which gets recorded by the userinterface is a zip-file called `wulpus-{date}-id-{connection}.zip`. +The date is the start of the recording, with the connection describes which wulpus was used (MAC address or COM port). +It contains the config from the job, as well as the data in a [parquet](https://parquet.apache.org/) file. + +The parquet-file contains the following rows: +- `tx`: list of channels used for sending +- `rx`: list of channels used for receiving +- `aq_number`: ID increased by the wulpus (can be used to detect lost frames) +- `log_version`: the current version of the log-format. Constant over the whole file. +- `tx_rx_id`: the index of the tx-rx-config used +- `0`, `1`, `2`, ...`{num samples - 1}`: the actual data, readout from ADC +- `__index_level_0__`: unix timestamp in us + +You can use the jupyter notebook [visualize_log.ipynb](visualize_log.ipynb) to load a single or a set of multiple measurements and evaluate them. +Ther's also [MATLAB_load_wulpus_log.m](MATLAB_load_wulpus_log.m) to help import the logs into MATLAB. + + +### Jupyter notebook (legacy) +How to Launch an example Jupyter notebook: +- In a terminal launch +``` + conda activate wulpus_env +``` +- Run the command below:
+ (or launch it from Start Menu on Windows:
+ *Start -> Anaconda3 -> Jupyter Notebook (wupus_env)*) +``` + jupyter notebook +``` +This opens a webpage: Navigate to `dev_pc` folder and click on `wulpus_gui.ipynb`. +Then, follow the instructions in the Notebook. + + +# More details +The folder structure inside `/sw` is as follows: +- `wulpus-frontend` is a react (javascript) project which is only the interface you see in your browser +- `wulpus` is a FastAPI server that controls communication with wulpus. +- `jupyter notebook (legacy)` is a standalone python-only solution which uses a jupyter notebook for controlling wulpus. + +During the build-step of `wulpus-frontend`, the build results are copied inside `wulpus\production-frontend`. This is how the frontend can be served from the `wulpus` FastAPI project. + +WULPUS GUI Schema + +## Development +If you want to work on the frontend, it's easier to just run the backen (`wulpus`) and the frontend (`wulpus-frontend`) seperate. +This way you don't have to build after each step. + +You can start the backend as usual (see above `python -m wulpus.main`), but instead of visiting [http://127.0.0.1:8000/](http://127.0.0.1:8000/) in your browser, you start the dev-environment of the frontend: +- Open an additonal terminal at `\sw\wulpus-frontend` +- run +``` + npm run dev +``` +- open the displayed link in your browser (probably [http://localhost:5173/](http://localhost:5173/)) + +This way do you access the frontend though its own dev-server, which does hot-reloading. +It still acesses the same backend, so everything still works exactly the same. # License The source files are released under Apache v2.0 (`Apache-2.0`) license unless noted otherwise, please refer to the `sw/LICENSE` file for details. \ No newline at end of file diff --git a/sw/convert_measurements.ipynb b/sw/convert_measurements.ipynb new file mode 100644 index 0000000..462967b --- /dev/null +++ b/sw/convert_measurements.ipynb @@ -0,0 +1,131 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "60c8ba1d", + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib widget\n", + "import ipywidgets as widgets\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.colors as colors\n", + "import numpy as np\n", + "import pandas as pd\n", + "from datetime import datetime\n", + "import os\n", + "import time" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f441f7e2", + "metadata": {}, + "outputs": [], + "source": [ + "old_file = '.\\\\wulpus\\\\measurements\\\\fliessfront-2025-08-29.npz'\n", + "recording = time.strptime(\"2025-08-29 14:00\", \"%Y-%m-%d %H:%M\")\n", + "\n", + "data = np.load(old_file)\n", + "path = os.path.dirname(old_file)\n", + "print('Keys:', data.files)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4fa3f542", + "metadata": {}, + "outputs": [], + "source": [ + "data_cnt = data['data_arr'].shape[1]\n", + "num_samples = data['data_arr'].shape[0]\n", + "measurements = data['data_arr'].T\n", + "\n", + "tx_channel = np.zeros((num_samples,1), dtype=np.uint8)\n", + "rx_channel = np.zeros((num_samples,1), dtype=np.uint8)\n", + "time = np.zeros(num_samples, dtype=np.float32)\n", + "\n", + "config = [\n", + " [[1, 2, 3, 4, 5, 6, 7], [0]],\n", + " [[0, 2, 3, 4, 5, 6, 7], [1]],\n", + " [[0, 1, 3, 4, 5, 6, 7], [2]],\n", + " [[0, 1, 2, 4, 5, 6, 7], [3]],\n", + " [[0, 1, 2, 3, 5, 6, 7], [4]],\n", + " [[0, 1, 2, 3, 4, 6, 7], [5]],\n", + " [[0, 1, 2, 3, 4, 5, 7], [6]],\n", + " [[0, 1, 2, 3, 4, 5, 6], [7]],\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "102b78be", + "metadata": {}, + "outputs": [], + "source": [ + "recording_dt = datetime(*recording[:6])\n", + "dates = pd.date_range(recording_dt, periods=num_samples, freq=\"s\")\n", + "df = pd.DataFrame({\n", + " 'measurement': [pd.Series(measurements[i]) for i in range(num_samples)], \n", + " \"tx\": [config[i % len(config)][0] for i in range(num_samples)], \n", + " \"rx\": [config[i % len(config)][1] for i in range(num_samples)],\n", + " \"aq_number\": [i for i in range(num_samples)],\n", + " \"log_version\": 1,\n", + " \"tx_rx_id\": [i % len(config) for i in range(num_samples)]\n", + " }, \n", + " index=dates)\n", + "df.head(15)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6416d86f", + "metadata": {}, + "outputs": [], + "source": [ + "# Expand measurement series into columns so they're all included in save-format (parquet)\n", + "import io\n", + "from zipfile import ZipFile\n", + "\n", + "measurement_expanded = pd.DataFrame(\n", + " [m.values for m in df['measurement']], index=df.index)\n", + "flattened_df = pd.concat(\n", + " [df.drop(columns=['measurement']), measurement_expanded], axis=1)\n", + "flattened_df.columns = [str(col) for col in flattened_df.columns]\n", + "\n", + "with ZipFile(\"converted.zip\", 'w') as zf:\n", + " # zf.writestr('config-0.json', config_json.dumps())\n", + " # Write dataframe as parquet\n", + " buffer = io.BytesIO()\n", + " flattened_df.to_parquet(buffer)\n", + " zf.writestr('data.parquet', buffer.getvalue())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sw/how_to_install_dependencies.md b/sw/how_to_install_dependencies.md deleted file mode 100644 index 60fc4cb..0000000 --- a/sw/how_to_install_dependencies.md +++ /dev/null @@ -1,23 +0,0 @@ -# How to install Python requirements? -1. Install Anaconda package manager
- https://docs.conda.io/en/latest/miniconda.html -2. Find `requirements.yaml` file in `dev_pc` folder -3. Open terminal (Windows: Anaconda Prompt) in `dev_pc` folder. -4. Execute the following command to create environment: -``` - conda env create -f requirements.yml -``` -5. In a new terminal launch -``` - conda activate wulpus_env -``` -6. and then run the command below:
- (or launch it from Start Menu on Windows:
- *Start -> Anaconda3 -> Jupyter Notebook (wupus_env)*) -``` - jupyter notebook -``` - - -7. The command above opens a webpage. Navigate to `dev_pc` folder and click on `wulpus_gui.ipynb`. - Then, follow the instructions in the Notebook. diff --git a/sw/.gitignore b/sw/jupyter notebook (legacy)/.gitignore similarity index 100% rename from sw/.gitignore rename to sw/jupyter notebook (legacy)/.gitignore diff --git a/sw/jupyter notebook (legacy)/LICENSE b/sw/jupyter notebook (legacy)/LICENSE new file mode 100644 index 0000000..cbbb3a6 --- /dev/null +++ b/sw/jupyter notebook (legacy)/LICENSE @@ -0,0 +1,70 @@ +Copyright (C) 2023 ETH Zurich. All rights reserved. + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + You must give any other recipients of the Work or Derivative Works a copy of this License; and + You must cause any modified files to carry prominent notices stating that You changed the files; and + You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/sw/examples/data_0.npz b/sw/jupyter notebook (legacy)/examples/data_0.npz similarity index 100% rename from sw/examples/data_0.npz rename to sw/jupyter notebook (legacy)/examples/data_0.npz diff --git a/sw/examples/tx_rx_configs.json b/sw/jupyter notebook (legacy)/examples/tx_rx_configs.json similarity index 100% rename from sw/examples/tx_rx_configs.json rename to sw/jupyter notebook (legacy)/examples/tx_rx_configs.json diff --git a/sw/examples/uss_config.json b/sw/jupyter notebook (legacy)/examples/uss_config.json similarity index 100% rename from sw/examples/uss_config.json rename to sw/jupyter notebook (legacy)/examples/uss_config.json diff --git a/sw/wulpus_gui.ipynb b/sw/jupyter notebook (legacy)/wulpus_gui.ipynb similarity index 96% rename from sw/wulpus_gui.ipynb rename to sw/jupyter notebook (legacy)/wulpus_gui.ipynb index 9d2a0f7..d9ea7a9 100644 --- a/sw/wulpus_gui.ipynb +++ b/sw/jupyter notebook (legacy)/wulpus_gui.ipynb @@ -108,7 +108,7 @@ }, "outputs": [], "source": [ - "import wulpus.rx_tx_conf_gui as conf_gui\n", + "import wulpus_jptnbk.rx_tx_conf_gui as conf_gui\n", "\n", "# Generate Transmit/Receive configs using the GUI\n", "conf_gen = conf_gui.WulpusRxTxConfigGenGUI()\n", @@ -139,7 +139,7 @@ "metadata": {}, "outputs": [], "source": [ - "from wulpus.rx_tx_conf import WulpusRxTxConfigGen\n", + "from wulpus_jptnbk.rx_tx_conf import WulpusRxTxConfigGen\n", "\n", "# COMMENT THE CODE IN THIS CELL WHEN USING TX/RX GUI \n", "\n", @@ -166,7 +166,7 @@ "metadata": {}, "outputs": [], "source": [ - "import wulpus.rx_tx_conf_gui as conf_gui\n", + "import wulpus_jptnbk.rx_tx_conf_gui as conf_gui\n", "\n", "# # load configurations directly from a file\n", "\n", @@ -191,8 +191,8 @@ "metadata": {}, "outputs": [], "source": [ - "from wulpus.uss_conf import WulpusUssConfig, PGA_GAIN\n", - "from wulpus.uss_conf_gui import WulpusUssConfigGUI\n", + "from wulpus_jptnbk.uss_conf import WulpusUssConfig, PGA_GAIN\n", + "from wulpus_jptnbk.uss_conf_gui import WulpusUssConfigGUI\n", "\n", "# Get TX/RX configurations\n", "tx_confs = conf_gen.get_tx_configs()\n", @@ -242,7 +242,7 @@ "metadata": {}, "outputs": [], "source": [ - "from wulpus.dongle import WulpusDongle\n", + "from wulpus_jptnbk.dongle import WulpusDongle\n", "\n", "# Create a dongle object\n", "dongle = WulpusDongle()" @@ -265,7 +265,7 @@ "outputs": [], "source": [ "%matplotlib widget\n", - "from wulpus.gui import WulpusGuiSingleCh\n", + "from wulpus_jptnbk.gui import WulpusGuiSingleCh\n", "\n", "# Create a GUI\n", "try:\n", diff --git a/sw/jupyter notebook (legacy)/wulpus_jptnbk/__init__.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sw/wulpus/config_package.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/config_package.py similarity index 100% rename from sw/wulpus/config_package.py rename to sw/jupyter notebook (legacy)/wulpus_jptnbk/config_package.py diff --git a/sw/wulpus/dongle.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/dongle.py similarity index 100% rename from sw/wulpus/dongle.py rename to sw/jupyter notebook (legacy)/wulpus_jptnbk/dongle.py diff --git a/sw/wulpus/gui.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/gui.py similarity index 73% rename from sw/wulpus/gui.py rename to sw/jupyter notebook (legacy)/wulpus_jptnbk/gui.py index 8d6f72a..7ed746f 100644 --- a/sw/wulpus/gui.py +++ b/sw/jupyter notebook (legacy)/wulpus_jptnbk/gui.py @@ -24,65 +24,75 @@ from threading import Thread import os.path -from wulpus.dongle import WulpusDongle +from wulpus_jptnbk.dongle import WulpusDongle # plt.ioff() -V_TISSUE = 1540 # m/s +V_TISSUE = 1540 # m/s -LOWER_BOUNDS_MM = 7 # data below this depth will be discarded +LOWER_BOUNDS_MM = 7 # data below this depth will be discarded LINE_N_SAMPLES = 400 FILE_NAME_BASE = 'data_' -box_layout = widgets.Layout(display='flex', - flex_flow='column', - align_items='center', - width='50%') class WulpusGuiSingleCh(widgets.VBox): - - def __init__(self, com_link:WulpusDongle, uss_conf, max_vis_fps = 20): + + def __init__(self, com_link: WulpusDongle, uss_conf, max_vis_fps=20): super().__init__() - + # Communication link self.com_link = com_link - + # Ultrasound Subsystem Configurator self.uss_conf = uss_conf - + # Allocate memory to store the data and other parameters - self.data_arr = np.zeros((self.com_link.acq_length, uss_conf.num_acqs), dtype=' 0: device = self.found_devices[self.ports_dd.index] - + if not self.com_link.open(device): b.description = "Open port" self.port_opened = False self.start_stop_button.disabled = True return - + b.description = "Close port" self.port_opened = True self.start_stop_button.disabled = False - - else : + + else: self.com_link.close() b.description = "Open port" self.port_opened = False self.start_stop_button.disabled = True - - + def turn_on_off_raw_data_plot(self, change): - + self.raw_data_line.set_visible(change.new) - + def turn_on_off_filt_data_plot(self, change): - + self.filt_data_line.set_visible(change.new) - + def turn_on_off_env_plot(self, change): - + self.envelope_line.set_visible(change.new) def toggle_bmode(self, change): - + if change.new: self.setup_bmode_plot() self.tx_rx_sel_dd.disabled = True else: self.setup_amode_plot() self.tx_rx_sel_dd.disabled = False - + self.fig.canvas.draw() self.fig.canvas.flush_events() - + def select_rx_conf_to_plot(self, change): - + self.rx_tx_conf_to_display = int(change.new) - + def update_band_pass_range(self, change): - + self.design_filter(self.uss_conf.sampling_freq, change.new[0]*10**6, change.new[1]*10**6) - - + + def disable_all_widgets(self, state: bool): + + self.raw_data_check.disabled = state + self.filt_data_check.disabled = state + self.env_data_check.disabled = state + self.bmode_check.disabled = state + self.tx_rx_sel_dd.disabled = state + self.band_pass_frs.disabled = state + self.save_data_check.disabled = state + def click_start_stop_acq(self, b): if not self.acquisition_running: - # Enable the widgets active during acquisition - self.raw_data_check.disabled = False - self.filt_data_check.disabled = False - self.env_data_check.disabled = False - self.bmode_check.disabled = False - self.tx_rx_sel_dd.disabled = False - self.band_pass_frs.disabled = False - self.save_data_check.disabled = False - + self.disable_all_widgets(False) + # Disable serial port related widgets self.ser_open_button.disabled = True - + # Clean Save data label self.save_data_label.value = '' - - + # Change state of the button b.description = "Stop measurement" # Declare that acquisition is running self.acquisition_running = True + self.record_start = time.time() # Run data acquisition loop self.current_data = None self.acquisition_thread = Thread(target=self.run_acquisition_loop) self.acquisition_thread.start() - + else: # Stop acquisition, thread will stop by itself self.acquisition_running = False - + # Change state of the button b.description = "Start measurement" - # Disable the widgets when not acquiring - self.raw_data_check.disabled = True - self.filt_data_check.disabled = True - self.env_data_check.disabled = True - self.bmode_check.disabled = True - self.tx_rx_sel_dd.disabled = True - self.band_pass_frs.disabled = True - self.save_data_check.disabled = True - + self.disable_all_widgets(True) + # Enable serial port related widgets again self.ser_open_button.disabled = False - + def run_acquisition_loop(self): -# self.fig.show() - # Clean data buffer acq_length = self.com_link.acq_length number_of_acq = self.uss_conf.num_acqs @@ -397,14 +431,14 @@ def run_acquisition_loop(self): self.acq_num_arr = np.zeros(number_of_acq, dtype=' 0: time.sleep(sleep_time) - # Design bandpass filter - def design_filter(self, - f_sampling, - f_low_cutoff, - f_high_cutoff, - trans_width=0.2*10**6, - n_taps = 31): - - temp = [0, f_low_cutoff - trans_width, \ - f_low_cutoff, f_high_cutoff, \ - f_high_cutoff + trans_width, \ + def design_filter(self, + f_sampling, + f_low_cutoff, + f_high_cutoff, + trans_width=0.2*10**6, + n_taps=31): + + temp = [0, f_low_cutoff - trans_width, + f_low_cutoff, f_high_cutoff, + f_high_cutoff + trans_width, f_sampling/2] - - self.filt_b = ss.remez(n_taps, + + self.filt_b = ss.remez(n_taps, temp, - [0, 1, 0], - Hz=f_sampling, + [0, 1, 0], + Hz=f_sampling, maxiter=2500) self.filt_a = 1 - - + def filter_data(self, data_in): return ss.filtfilt(self.filt_b, self.filt_a, data_in) - + def get_envelope(self, data_in): return np.abs(hilbert(data_in)) - + def save_data_to_file(self): - - # Check filename - for i in range(100): - filename = FILE_NAME_BASE + str(i) + '.npz' - if not os.path.isfile(filename): - break - + start_time = time.localtime(self.record_start) + timestring = time.strftime("%Y-%m-%d_%H-%M-%S", start_time) + + filename = FILE_NAME_BASE + timestring + # Check if filename exists (.npz gets added by .savez command) + while os.path.isfile(filename + '.npz'): + filename = filename + "_conflict" + # Save numpy data array to file - np.savez(filename[:-4], - data_arr=self.data_arr, - acq_num_arr=self.acq_num_arr, - tx_rx_id_arr=self.tx_rx_id_arr) - - self.save_data_label.value = 'Data saved in ' + filename \ No newline at end of file + np.savez_compressed(filename, + data_arr=self.data_arr, + acq_num_arr=self.acq_num_arr, + tx_rx_id_arr=self.tx_rx_id_arr, + record_start=np.array([self.record_start]) + ) + + self.save_data_label.value = 'Data saved in ' + filename + '.npz' diff --git a/sw/wulpus/rx_tx_conf.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/rx_tx_conf.py similarity index 60% rename from sw/wulpus/rx_tx_conf.py rename to sw/jupyter notebook (legacy)/wulpus_jptnbk/rx_tx_conf.py index 6f38bee..3799867 100644 --- a/sw/wulpus/rx_tx_conf.py +++ b/sw/jupyter notebook (legacy)/wulpus_jptnbk/rx_tx_conf.py @@ -98,4 +98,77 @@ def get_tx_configs(self): def get_rx_configs(self): return self.rx_configs[:self.tx_rx_len] - \ No newline at end of file + + +def build_tx_rx_configs_from_wulpus_config(wulpus_config): + """Build TX and RX configuration arrays from a WulpusConfig instance. + + The function replicates the logic of WulpusRxTxConfigGen.add_config for each + TxRxConfig entry inside wulpus_config.tx_rx_config, including the optional + optimized switching behavior. + + Parameters + ---------- + wulpus_config : object + Expected to expose an attribute `tx_rx_config` which is an iterable of + objects each having: tx_channels (list[int]), rx_channels (list[int]), + optimized_switching (bool). + + Returns + ------- + (np.ndarray, np.ndarray) + Tuple of (tx_configs, rx_configs) each shaped (N,) dtype '= TX_RX_MAX_NUM_OF_CONFIGS: + raise ValueError('Maximum number of configs is ' + + str(TX_RX_MAX_NUM_OF_CONFIGS)) + + tx_channels = list(getattr(cfg, 'tx_channels', [])) + rx_channels = list(getattr(cfg, 'rx_channels', [])) + optimized_switching = bool(getattr(cfg, 'optimized_switching', False)) + + # Validate channel IDs + if (any(ch > MAX_CH_ID for ch in tx_channels) or any(ch > MAX_CH_ID for ch in rx_channels)): + raise ValueError( + 'RX and TX channel ID must be less than ' + str(MAX_CH_ID)) + if (any(ch < 0 for ch in tx_channels) or any(ch < 0 for ch in rx_channels)): + raise ValueError('RX and TX channel ID must be positive.') + + # Build bitmasks + if len(tx_channels) == 0: + tx_cfgs[length] = 0 + else: + tx_cfgs[length] = np.bitwise_or.reduce( + np.left_shift(1, TX_MAP[tx_channels])) + + if len(rx_channels) == 0: + rx_cfgs[length] = 0 + else: + rx_cfgs[length] = np.bitwise_or.reduce( + np.left_shift(1, RX_MAP[rx_channels])) + + if optimized_switching: + rx_tx_intersect_ch = list(set(tx_channels) & set(rx_channels)) + rx_only_ch = list(set(rx_tx_intersect_ch) ^ set(rx_channels)) + tx_only_ch = list(set(rx_tx_intersect_ch) ^ set( + tx_channels)) # kept for clarity if later needed + + if len(rx_tx_intersect_ch) > len(rx_only_ch): + temp_switch_config = np.bitwise_or.reduce(np.left_shift( + 1, RX_MAP[rx_tx_intersect_ch])) if rx_tx_intersect_ch else 0 + tx_cfgs[length] = np.bitwise_or( + tx_cfgs[length], temp_switch_config) + elif len(rx_only_ch) > 0: + temp_switch_config = np.bitwise_or.reduce( + np.left_shift(1, RX_MAP[rx_only_ch])) + tx_cfgs[length] = np.bitwise_or( + tx_cfgs[length], temp_switch_config) + + length += 1 + + return tx_cfgs[:length], rx_cfgs[:length] diff --git a/sw/wulpus/rx_tx_conf_gui.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/rx_tx_conf_gui.py similarity index 81% rename from sw/wulpus/rx_tx_conf_gui.py rename to sw/jupyter notebook (legacy)/wulpus_jptnbk/rx_tx_conf_gui.py index 7dfc4f4..e2f79ad 100644 --- a/sw/wulpus/rx_tx_conf_gui.py +++ b/sw/jupyter notebook (legacy)/wulpus_jptnbk/rx_tx_conf_gui.py @@ -14,7 +14,7 @@ SPDX-License-Identifier: Apache-2.0 """ -from wulpus.rx_tx_conf import WulpusRxTxConfigGen, TX_RX_MAX_NUM_OF_CONFIGS, MAX_CH_ID +from wulpus_jptnbk.rx_tx_conf import WulpusRxTxConfigGen, TX_RX_MAX_NUM_OF_CONFIGS, MAX_CH_ID import ipywidgets as widgets import json @@ -25,7 +25,7 @@ class _Config(): """ Represents a single configuration with its ID, TX and RX channels, and enabled status. - + Attributes: config_id (int): The ID of the configuration. tx_channels (list): The TX channels of the configuration. @@ -41,10 +41,11 @@ def __init__(self, config_id): self.enabled = False self.optimized_switching = False + class _ConfigParser(): """ Manages a list of configurations, including saving them to and loading them from a JSON file. - + Attributes: configs (list): The list of configurations. gui (object): The GUI object. @@ -81,7 +82,7 @@ def save_json(self, filename): filename = filename.split('.')[0] filename = filename.join(['', '.json']) self.gui.label_info.value = 'Saving config(s) to "' + filename + '"' - + # open file and write configs with open(filename, 'w') as f: # define data structure @@ -103,7 +104,8 @@ def save_json(self, filename): # write data to file json.dump(data, f, indent=4) - self.gui.label_info.value = 'Saved ' + str(len(data['configs'])) + ' config(s) to "' + filename + '"' + self.gui.label_info.value = 'Saved ' + \ + str(len(data['configs'])) + ' config(s) to "' + filename + '"' def parse_json(self, filename): """ @@ -117,7 +119,7 @@ def parse_json(self, filename): filename = filename.split('.')[0] filename = filename.join(['', '.json']) self.gui.label_info.value = 'Loading config(s) from ' + filename - + try: # open file and write configs with open(filename, 'r') as f: @@ -135,7 +137,7 @@ def parse_json(self, filename): print('No configs found in file.') self.gui.label_info.value = 'No config(s) found in file.' return - + # check if there are too many configs (should never be the case if GUI is used) if len(configs) > TX_RX_MAX_NUM_OF_CONFIGS: print('Too many configs found in file.') @@ -153,24 +155,30 @@ def parse_json(self, filename): for config in configs: # check if config has all required fields (should always be the case if GUI is used) if 'config_id' not in config or 'tx_channels' not in config or \ - 'rx_channels' not in config or 'optimized_switching' not in config: + 'rx_channels' not in config or 'optimized_switching' not in config: print('Invalid config found in file.') self.gui.label_info.value = 'Invalid config found in file.' return - + # add config to list self.configs[config['config_id']].enabled = True - self.configs[config['config_id']].optimized_switching = config['optimized_switching'] - self.configs[config['config_id']].tx_channels = config['tx_channels'] - self.configs[config['config_id']].rx_channels = config['rx_channels'] + self.configs[config['config_id'] + ].optimized_switching = config['optimized_switching'] + self.configs[config['config_id'] + ].tx_channels = config['tx_channels'] + self.configs[config['config_id'] + ].rx_channels = config['rx_channels'] - self.gui.label_info.value = 'Loaded ' + str(len(configs)) + ' config(s) from "' + filename + '"' + self.gui.label_info.value = 'Loaded ' + \ + str(len(configs)) + ' config(s) from "' + filename + '"' except FileNotFoundError: # if file not found, display error message and return self.gui.label_info.value = 'File "' + filename + '" not found.' # inherit from WulpusRxTxConfigGen + + class WulpusRxTxConfigGenGUI(widgets.VBox): """ A GUI for managing the TX and RX configurations of Wulpus. @@ -202,39 +210,47 @@ def __init__(self): # dropdown of all configs in the list self.dropdown_configs = widgets.Dropdown( - options = [i for i in range(0, TX_RX_MAX_NUM_OF_CONFIGS)], - index = 0, - description = 'Config:', - disabled = False + options=[i for i in range(0, TX_RX_MAX_NUM_OF_CONFIGS)], + index=0, + description='Config:', + disabled=False ) - self.dropdown_configs.observe(self.on_dropdown_configs_change, names='index') + self.dropdown_configs.observe( + self.on_dropdown_configs_change, names='index') # checkbox for enabling/disabling the chosen config self.check_config_enabled = widgets.Checkbox( - value = self.configs[self.config_index_selected].enabled, - description = 'Enabled', - disabled = False + value=self.configs[self.config_index_selected].enabled, + description='Enabled', + disabled=False ) - self.check_config_enabled.observe(self.on_check_config_enabled_change, names='value') + self.check_config_enabled.observe( + self.on_check_config_enabled_change, names='value') # checkbox for enabling/disabling the optimised switching for chosen config self.check_config_optimized_switching = widgets.Checkbox( - value = self.configs[self.config_index_selected].optimized_switching, - description = 'Optimize Switching', - disabled = False + value=self.configs[self.config_index_selected].optimized_switching, + description='Optimize Switching', + disabled=False ) - self.check_config_optimized_switching.observe(self.on_check_optimized_switching_change, names='value') + self.check_config_optimized_switching.observe( + self.on_check_optimized_switching_change, names='value') # label for displaying information self.label_info = widgets.Label(value='') - config_hbox = widgets.HBox([self.dropdown_configs, self.check_config_enabled, self.check_config_optimized_switching], layout=widgets.Layout(align_items='flex-start', justify_content='flex-start', width=GUI_WIDTH)) + config_hbox = widgets.HBox([self.dropdown_configs, self.check_config_enabled, self.check_config_optimized_switching], + layout=widgets.Layout(align_items='flex-start', justify_content='flex-start', width=GUI_WIDTH)) # buttons for selecting TX and RX channels - self.buttons_channels_tx = [widgets.Button(description=str(i), button_style='', layout=widgets.Layout(width='12%')) for i in range(0, MAX_CH_ID+1)] + self.buttons_channels_tx = [widgets.Button(description=str( + i), button_style='', layout=widgets.Layout(width='12%')) for i in range(0, MAX_CH_ID+1)] for tx_button in self.buttons_channels_tx: tx_button.on_click(self.on_button_channel_tx_click) - buttons_tx_hbox = widgets.HBox([widgets.Label(value='TX', layout=widgets.Layout(width='25px'))] + self.buttons_channels_tx, layout=widgets.Layout(width=GUI_WIDTH, justify_content='space-between', align_items='center')) - self.buttons_channels_rx = [widgets.Button(description=str(i), button_style='', layout=widgets.Layout(width='12%')) for i in range(0, MAX_CH_ID+1)] + buttons_tx_hbox = widgets.HBox([widgets.Label(value='TX', layout=widgets.Layout( + width='25px'))] + self.buttons_channels_tx, layout=widgets.Layout(width=GUI_WIDTH, justify_content='space-between', align_items='center')) + self.buttons_channels_rx = [widgets.Button(description=str( + i), button_style='', layout=widgets.Layout(width='12%')) for i in range(0, MAX_CH_ID+1)] for rx_button in self.buttons_channels_rx: rx_button.on_click(self.on_button_channel_rx_click) - buttons_rx_hbox = widgets.HBox([widgets.Label(value='RX', layout=widgets.Layout(width='25px'))] + self.buttons_channels_rx, layout=widgets.Layout(width=GUI_WIDTH, justify_content='space-between', align_items='center')) + buttons_rx_hbox = widgets.HBox([widgets.Label(value='RX', layout=widgets.Layout( + width='25px'))] + self.buttons_channels_rx, layout=widgets.Layout(width=GUI_WIDTH, justify_content='space-between', align_items='center')) buttons_hbox = widgets.VBox([buttons_tx_hbox, buttons_rx_hbox]) # text field and buttons for saving/loading configs to/from file @@ -258,19 +274,25 @@ def __init__(self): icon='upload' ) self.load_button.on_click(self.on_load_button_click) - file_buttons_hbox = widgets.HBox([self.entry_filename, self.save_button, self.load_button], layout=widgets.Layout(align_items='flex-start', justify_content='flex-start', width=GUI_WIDTH)) + file_buttons_hbox = widgets.HBox([self.entry_filename, self.save_button, self.load_button], layout=widgets.Layout( + align_items='flex-start', justify_content='flex-start', width=GUI_WIDTH)) # initialize GUI self.update_buttons() - self.children = [config_hbox, buttons_hbox, file_buttons_hbox, self.label_info] # , self.output] # uncomment to display console output - + # , self.output] # uncomment to display console output + self.children = [config_hbox, buttons_hbox, + file_buttons_hbox, self.label_info] + def update_buttons(self): # update visualization of buttons self.check_config_enabled.value = self.configs[self.config_index_selected].enabled - self.check_config_optimized_switching.value = self.configs[self.config_index_selected].optimized_switching + self.check_config_optimized_switching.value = self.configs[ + self.config_index_selected].optimized_switching for i in range(0, MAX_CH_ID+1): - self.buttons_channels_tx[i].button_style = 'success' if i in self.configs[self.config_index_selected].tx_channels else 'danger' - self.buttons_channels_rx[i].button_style = 'success' if i in self.configs[self.config_index_selected].rx_channels else 'danger' + self.buttons_channels_tx[i].button_style = 'success' if i in self.configs[ + self.config_index_selected].tx_channels else 'danger' + self.buttons_channels_rx[i].button_style = 'success' if i in self.configs[ + self.config_index_selected].rx_channels else 'danger' def on_dropdown_configs_change(self, change): # update currently chosen config from dropdown @@ -293,12 +315,14 @@ def on_button_channel_tx_click(self, button): # update TX channels of currently chosen config from button with self.output: channel_id = int(button.description) - + # see if channel id is already in the list, if so, remove it, otherwise add it if channel_id in self.configs[self.config_index_selected].tx_channels: - self.configs[self.config_index_selected].tx_channels.remove(channel_id) + self.configs[self.config_index_selected].tx_channels.remove( + channel_id) else: - self.configs[self.config_index_selected].tx_channels.append(channel_id) + self.configs[self.config_index_selected].tx_channels.append( + channel_id) self.update_buttons() @@ -306,12 +330,14 @@ def on_button_channel_rx_click(self, button): # update RX channels of currently chosen config from button with self.output: channel_id = int(button.description) - + # see if channel id is already in the list, if so, remove it, otherwise add it if channel_id in self.configs[self.config_index_selected].rx_channels: - self.configs[self.config_index_selected].rx_channels.remove(channel_id) + self.configs[self.config_index_selected].rx_channels.remove( + channel_id) else: - self.configs[self.config_index_selected].rx_channels.append(channel_id) + self.configs[self.config_index_selected].rx_channels.append( + channel_id) self.update_buttons() @@ -325,7 +351,6 @@ def on_save_button_click(self, button): # save configs to file using _ConfigParser helper class _ConfigParser(self.configs, self).save_json(filename) - def on_load_button_click(self, button): # load configs from file @@ -362,7 +387,7 @@ def add_config(self, tx_channels, rx_channels): # if not, display error message self.label_info.value = 'No free config found.' - + def with_file(self, filename): """ With this method, the filename can be set directly for saving/loading configs, without having to enter it in the GUI. @@ -370,10 +395,10 @@ def with_file(self, filename): Args: filename (str): The filename of the JSON file, with or without the .json extension. """ - + # load configs from file using _ConfigParser helper class _ConfigParser(self.configs, self).parse_json(filename) - + return self def get_tx_configs(self): @@ -391,14 +416,15 @@ def get_tx_configs(self): config_found = False for config in self.configs: if config.enabled: - conf_gen.add_config(config.tx_channels, config.rx_channels, config.optimized_switching) + conf_gen.add_config( + config.tx_channels, config.rx_channels, config.optimized_switching) config_found = True if not config_found: raise ValueError('No enabled config found.') # return TX configs return conf_gen.get_tx_configs() - + def get_rx_configs(self): """ Returns the RX configurations as a list of dictionaries. @@ -414,11 +440,11 @@ def get_rx_configs(self): config_found = False for config in self.configs: if config.enabled: - conf_gen.add_config(config.tx_channels, config.rx_channels, config.optimized_switching) + conf_gen.add_config( + config.tx_channels, config.rx_channels, config.optimized_switching) config_found = True if not config_found: raise ValueError('No enabled config found.') # return RX configs return conf_gen.get_rx_configs() - diff --git a/sw/jupyter notebook (legacy)/wulpus_jptnbk/test_rx_tx_conf.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/test_rx_tx_conf.py new file mode 100644 index 0000000..9a41db5 --- /dev/null +++ b/sw/jupyter notebook (legacy)/wulpus_jptnbk/test_rx_tx_conf.py @@ -0,0 +1,188 @@ +import pytest +import numpy as np +import types + +from wulpus_jptnbk.rx_tx_conf import WulpusRxTxConfigGen, TX_RX_MAX_NUM_OF_CONFIGS, MAX_CH_ID, RX_MAP, TX_MAP + + +def bits_set_positions(value: int): + return [i for i in range(16) if (value >> i) & 1] + + +def test_add_single_tx_single_rx(): + gen = WulpusRxTxConfigGen() + gen.add_config([0], [1]) + tx = gen.get_tx_configs() + rx = gen.get_rx_configs() + assert len(tx) == 1 and len(rx) == 1 + # TX channel 0 -> TX_MAP[0] + assert bits_set_positions(tx[0]) == [TX_MAP[0]] + # RX channel 1 -> RX_MAP[1] + assert bits_set_positions(rx[0]) == [RX_MAP[1]] + + +def test_add_multiple_channels(): + gen = WulpusRxTxConfigGen() + gen.add_config([0, 2, 4], [1, 3]) + tx_bits = bits_set_positions(gen.get_tx_configs()[0]) + rx_bits = bits_set_positions(gen.get_rx_configs()[0]) + assert sorted(tx_bits) == sorted(TX_MAP[[0, 2, 4]].tolist()) + assert sorted(rx_bits) == sorted(RX_MAP[[1, 3]].tolist()) + + +def test_empty_lists(): + gen = WulpusRxTxConfigGen() + gen.add_config([], []) + assert gen.get_tx_configs()[0] == 0 + assert gen.get_rx_configs()[0] == 0 + + +def test_invalid_channel_high(): + gen = WulpusRxTxConfigGen() + with pytest.raises(ValueError): + gen.add_config([MAX_CH_ID+1], []) + with pytest.raises(ValueError): + gen.add_config([], [MAX_CH_ID+2]) + + +def test_invalid_channel_negative(): + gen = WulpusRxTxConfigGen() + with pytest.raises(ValueError): + gen.add_config([-1], []) + with pytest.raises(ValueError): + gen.add_config([], [-2]) + + +def test_max_number_of_configs(): + gen = WulpusRxTxConfigGen() + for _ in range(TX_RX_MAX_NUM_OF_CONFIGS): + gen.add_config([0], [0]) + assert len(gen.get_tx_configs()) == TX_RX_MAX_NUM_OF_CONFIGS + with pytest.raises(ValueError): + gen.add_config([0], [0]) + + +def test_length_counters_increase(): + gen = WulpusRxTxConfigGen() + for i in range(5): + gen.add_config([i % (MAX_CH_ID+1)], [(i+1) % (MAX_CH_ID+1)]) + assert gen.tx_rx_len == i+1 + + +def test_optimized_switching_intersection_more_than_rx_only(): + gen = WulpusRxTxConfigGen() + # tx & rx share two channels (0,1); rx only one (2) + gen.add_config([0, 1, 3], [0, 1, 2], optimized_switching=True) + tx_val = gen.get_tx_configs()[0] + # Expect RX channels in intersect (0,1) added to TX config + for ch in [0, 1]: + assert (tx_val & (1 << RX_MAP[ch])) != 0 + + +def test_optimized_switching_rx_only_more_than_intersection(): + gen = WulpusRxTxConfigGen() + # intersect = {0}; rx_only = {1,2} + gen.add_config([0, 3], [0, 1, 2], optimized_switching=True) + tx_val = gen.get_tx_configs()[0] + # Expect rx_only channels 1,2 pre-enabled in TX config + for ch in [1, 2]: + assert (tx_val & (1 << RX_MAP[ch])) != 0 + + +def test_no_optimized_switching_changes(): + gen = WulpusRxTxConfigGen() + gen.add_config([0, 2], [1, 3], optimized_switching=False) + tx_val = gen.get_tx_configs()[0] + # Ensure no RX bits accidentally set + assert all((tx_val & (1 << RX_MAP[ch])) == 0 for ch in [1, 3]) + + +def test_get_tx_rx_configs_slices(): + gen = WulpusRxTxConfigGen() + gen.add_config([0], [0]) + gen.add_config([1], [1]) + tx = gen.get_tx_configs() + rx = gen.get_rx_configs() + assert tx.shape[0] == 2 and rx.shape[0] == 2 + # Internal arrays larger than length should remain zero outside slice + assert np.all(gen.tx_configs[2:] == 0) + assert np.all(gen.rx_configs[2:] == 0) + + +def test_repeated_add_same_config(): + gen = WulpusRxTxConfigGen() + gen.add_config([0], [0]) + first_tx = gen.get_tx_configs()[0] + gen.add_config([0], [0]) + second_tx = gen.get_tx_configs()[1] + assert first_tx == second_tx + + +def test_mixed_zero_and_valid_channels(): + gen = WulpusRxTxConfigGen() + gen.add_config([0, MAX_CH_ID], [0, MAX_CH_ID]) + tx_bits = bits_set_positions(gen.get_tx_configs()[0]) + rx_bits = bits_set_positions(gen.get_rx_configs()[0]) + assert TX_MAP[0] in tx_bits and TX_MAP[MAX_CH_ID] in tx_bits + assert RX_MAP[0] in rx_bits and RX_MAP[MAX_CH_ID] in rx_bits + + +def test_build_tx_rx_configs_from_wulpus_config_basic(): + from wulpus_jptnbk.rx_tx_conf import build_tx_rx_configs_from_wulpus_config + cfg_entry = types.SimpleNamespace(tx_channels=[0, 2], rx_channels=[ + 1, 3], optimized_switching=False) + wcfg = types.SimpleNamespace(tx_rx_config=[cfg_entry]) + tx, rx = build_tx_rx_configs_from_wulpus_config(wcfg) + assert tx.shape == (1,) and rx.shape == (1,) + assert (tx[0] & (1 << TX_MAP[0])) != 0 + assert (tx[0] & (1 << TX_MAP[2])) != 0 + assert (rx[0] & (1 << RX_MAP[1])) != 0 + assert (rx[0] & (1 << RX_MAP[3])) != 0 + + +def test_build_tx_rx_configs_optimized_intersection(): + from wulpus_jptnbk.rx_tx_conf import build_tx_rx_configs_from_wulpus_config + # intersection channels 0,1 > rx_only [2] + cfg_entry = types.SimpleNamespace(tx_channels=[0, 1, 4], rx_channels=[ + 0, 1, 2], optimized_switching=True) + wcfg = types.SimpleNamespace(tx_rx_config=[cfg_entry]) + tx, rx = build_tx_rx_configs_from_wulpus_config(wcfg) + # expect RX_MAP[0] and RX_MAP[1] bits added into TX + for ch in [0, 1]: + assert (tx[0] & (1 << RX_MAP[ch])) != 0 + + +def test_build_tx_rx_configs_optimized_rx_only(): + from wulpus_jptnbk.rx_tx_conf import build_tx_rx_configs_from_wulpus_config + # intersection channel 0, rx_only 1,2 + cfg_entry = types.SimpleNamespace(tx_channels=[0, 4], rx_channels=[ + 0, 1, 2], optimized_switching=True) + wcfg = types.SimpleNamespace(tx_rx_config=[cfg_entry]) + tx, rx = build_tx_rx_configs_from_wulpus_config(wcfg) + for ch in [1, 2]: + assert (tx[0] & (1 << RX_MAP[ch])) != 0 + + +def test_build_tx_rx_configs_multiple_entries(): + from wulpus_jptnbk.rx_tx_conf import build_tx_rx_configs_from_wulpus_config + entries = [ + types.SimpleNamespace(tx_channels=[0], rx_channels=[ + 0], optimized_switching=False), + types.SimpleNamespace(tx_channels=[1, 2], rx_channels=[ + 3], optimized_switching=False), + ] + wcfg = types.SimpleNamespace(tx_rx_config=entries) + tx, rx = build_tx_rx_configs_from_wulpus_config(wcfg) + assert tx.shape == (2,) and rx.shape == (2,) + assert (tx[0] & (1 << TX_MAP[0])) != 0 + assert (rx[1] & (1 << RX_MAP[3])) != 0 + + +def test_build_tx_rx_configs_channel_validation(): + from wulpus_jptnbk.rx_tx_conf import build_tx_rx_configs_from_wulpus_config + bad_entry = types.SimpleNamespace( + tx_channels=[MAX_CH_ID+1], rx_channels=[], optimized_switching=False) + wcfg = types.SimpleNamespace(tx_rx_config=[bad_entry]) + import pytest + with pytest.raises(ValueError): + build_tx_rx_configs_from_wulpus_config(wcfg) diff --git a/sw/wulpus/uss_conf.py b/sw/jupyter notebook (legacy)/wulpus_jptnbk/uss_conf.py similarity index 55% rename from sw/wulpus/uss_conf.py rename to sw/jupyter notebook (legacy)/wulpus_jptnbk/uss_conf.py index f9d7261..f5d81d8 100644 --- a/sw/wulpus/uss_conf.py +++ b/sw/jupyter notebook (legacy)/wulpus_jptnbk/uss_conf.py @@ -16,15 +16,15 @@ """ import numpy as np -from wulpus.config_package import * +from wulpus_jptnbk.config_package import * # CONSTANTS # Protocol related START_BYTE_CONF_PACK = 250 -START_BYTE_RESTART = 251 +START_BYTE_RESTART = 251 # Maximum length of the configuration package -PACKAGE_LEN = 68 +PACKAGE_LEN = 68 class WulpusUssConfig(): @@ -56,7 +56,7 @@ class WulpusUssConfig(): def __init__(self, num_acqs=100, - dcdc_turnon=195300, + dcdc_turnon=195300, meas_period=321965, trans_freq=225e4, pulse_freq=225e4, @@ -64,78 +64,86 @@ def __init__(self, sampling_freq=USS_CAPTURE_ACQ_RATES[0], num_samples=400, rx_gain=PGA_GAIN[-10], - num_txrx_configs= 1, - tx_configs = [0], - rx_configs = [0], - start_hvmuxrx=500, - start_ppg=500, - turnon_adc=5, + num_txrx_configs=1, + tx_configs=[0], + rx_configs=[0], + start_hvmuxrx=500, + start_ppg=500, + turnon_adc=5, start_pgainbias=5, start_adcsampl=503, restart_capt=3000, capt_timeout=3000): - + # check if sampling frequency is valid if sampling_freq not in USS_CAPTURE_ACQ_RATES: - raise ValueError('Sampling frequency of ' + str(sampling_freq) + ' is not allowed.\nAllowed values are: ' + str(USS_CAPTURE_ACQ_RATES)) + raise ValueError('Sampling frequency of ' + str(sampling_freq) + + ' is not allowed.\nAllowed values are: ' + str(USS_CAPTURE_ACQ_RATES)) # check if rx gain is valid if rx_gain not in PGA_GAIN: - raise ValueError('RX gain of ' + str(rx_gain) + ' is not allowed.\nAllowed values are: ' + str(PGA_GAIN)) - + raise ValueError('RX gain of ' + str(rx_gain) + + ' is not allowed.\nAllowed values are: ' + str(PGA_GAIN)) + # Parse basic settings - self.num_acqs = int(num_acqs) - self.dcdc_turnon = int(dcdc_turnon) - self.meas_period = int(meas_period) - self.trans_freq = int(trans_freq) - self.pulse_freq = int(pulse_freq) - self.num_pulses = int(num_pulses) - self.sampling_freq = int(sampling_freq) - self.num_samples = int(num_samples) - self.rx_gain = float(rx_gain) - self.num_txrx_configs = int(num_txrx_configs) - self.tx_configs = np.array(tx_configs).astype(' entry.max: - print("Warning: " + param.friendly_name + " is set to " + str(value) + " which is above the allowed range [" + str(entry.min) + ", " + str(entry.max) + "].") + print("Warning: " + param.friendly_name + " is set to " + str( + value) + " which is above the allowed range [" + str(entry.min) + ", " + str(entry.max) + "].") value = entry.max - print(" Setting " + param.friendly_name + " to " + str(value) + ".") + print( + " Setting " + param.friendly_name + " to " + str(value) + ".") elif param.limit_type == 'list': # Check if value is located in the list of allowed values if value not in entry.options: - print("Warning: " + param.friendly_name + " is set to " + str(value) + " which is not allowed. Allowed values are: " + str(entry.options)) + print("Warning: " + param.friendly_name + " is set to " + str( + value) + " which is not allowed. Allowed values are: " + str(entry.options)) # Get nearest allowed value - value = min(entry.options, key=lambda x:abs(x-value)) - print(" Setting " + param.friendly_name + " to " + str(value) + ".") + value = min(entry.options, + key=lambda x: abs(x-value)) + print( + " Setting " + param.friendly_name + " to " + str(value) + ".") setattr(self, param.config_name, value) @@ -282,7 +303,8 @@ def load_json(self, button): # Check if the parameter is an advanced setting try: - setattr(self, param.config_name, data[param.config_name]) + setattr(self, param.config_name, + data[param.config_name]) except KeyError: # If the parameter is not in the JSON file, # just keep the current value @@ -298,7 +320,8 @@ def load_json(self, button): # Check if the parameter is a GUI setting try: - setattr(self, param.config_name, data[param.config_name]) + setattr(self, param.config_name, + data[param.config_name]) except KeyError: # If the parameter is not in the JSON file, # just keep the current value @@ -315,7 +338,7 @@ def load_json(self, button): self.info_label.value = f'File {filename} not found' return - + self.convert_to_registers() self.info_label.value = f'Loaded configuration from {filename}' diff --git a/sw/requirements.yml b/sw/requirements.yml index 64b6f10..8c57aec 100644 Binary files a/sw/requirements.yml and b/sw/requirements.yml differ diff --git a/sw/visualize_log.ipynb b/sw/visualize_log.ipynb new file mode 100644 index 0000000..40660fa --- /dev/null +++ b/sw/visualize_log.ipynb @@ -0,0 +1,298 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "60c8ba1d", + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib widget\n", + "import ipywidgets as widgets\n", + "from ipywidgets import interact, fixed\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib import colors\n", + "import numpy as np\n", + "import pandas as pd\n", + "from wulpus.helper import zip_to_dataframe, find_latest_measurement_zip, concat_dataframes, get_all_zips_from_folder\n", + "from wulpus.plot_helpers import flatten_df_measurements, imshow_with_time\n", + "import os\n", + "import glob\n", + "import wulpus as wulpus_pkg\n", + "import inspect" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08aab591", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "paths = find_latest_measurement_zip()\n", + "# paths = get_all_zips_from_folder(\".\\\\wulpus\\\\measurements\\\\specialFolder\")\n", + "# paths = [\n", + "# \".\\\\wulpus\\\\measurements\\\\wulpus-2025-09-05_11-01-36.zip\"\n", + "# ]\n", + "SAMPLE_CROP = 100 # no crop e.g. 99999" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f441f7e2", + "metadata": {}, + "outputs": [], + "source": [ + "df, config = zip_to_dataframe(paths[0])\n", + "\n", + "if len(paths) > 1:\n", + " print(f\"Multiple ({len(paths)}) paths detected, concatenating DataFrames\")\n", + " for p in paths[1:]:\n", + " df_new, config_new = zip_to_dataframe(p)\n", + " df = concat_dataframes([df, df_new])\n", + " config = config_new\n", + "print(df.info())\n", + "df.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4292405", + "metadata": {}, + "outputs": [], + "source": [ + "data_sel = np.stack(df['measurement'].to_numpy())\n", + "channel_id = 0\n", + "\n", + "def plot(frame:int, initial:bool = False):\n", + " plt.clf()\n", + " plt.plot(data_sel[frame], linestyle='-', marker='o', linewidth=1, markersize=2)\n", + " plt.ylim(-2500, 2500)\n", + " plt.title(f'(Channel {channel_id})')\n", + " plt.xlabel('Samples')\n", + " plt.ylabel('ADC digital code')\n", + " plt.grid()\n", + " plt.title(f'Acquisition {frame}/{data_sel.shape[0]-1} (Channel {channel_id})')\n", + " if initial:\n", + " plt.show()\n", + " else:\n", + " plt.draw()\n", + "\n", + "plt.figure(figsize=(10, 5))\n", + "plot(0, initial=True)\n", + "\n", + "interact(lambda frame: plot(frame, False), frame=widgets.IntSlider(min=0, max=data_sel.shape[0]-1, step=1, value=0))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24d8965a", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Grouped heatmaps by tx_rx_id with shared colorbar and shared x-label\n", + "from mpl_toolkits.axes_grid1.inset_locator import inset_axes\n", + "\n", + "groups = list(df.groupby('tx_rx_id'))\n", + "n_groups = len(groups)\n", + "if n_groups == 0:\n", + " raise ValueError('No groups found for tx_rx_id')\n", + "\n", + "# First pass: compute global vmin/vmax across all groups (after flattening and crop)\n", + "global_min, global_max = None, None\n", + "flattened_cache = []\n", + "groups.sort(key=lambda x: x[1]['rx'].iloc[0])\n", + "\n", + "for txrx, group_df in groups:\n", + " flat_df, meas_cols = flatten_df_measurements(group_df, sample_crop=SAMPLE_CROP)\n", + " values = flat_df[meas_cols].to_numpy(dtype=float) # (n_acq, n_samples)\n", + " plot_data = values.T # (n_samples, n_acq)\n", + " vmin = np.nanmin(plot_data) if plot_data.size else None\n", + " vmax = np.nanmax(plot_data) if plot_data.size else None\n", + " flattened_cache.append((txrx, group_df, flat_df, meas_cols, plot_data))\n", + " if vmin is not None:\n", + " global_min = vmin if global_min is None else min(global_min, vmin)\n", + " if vmax is not None:\n", + " global_max = vmax if global_max is None else max(global_max, vmax)\n", + "\n", + "# Build a common norm\n", + "if global_min is None or global_max is None:\n", + " global_min, global_max = -1, 1\n", + "norm = colors.SymLogNorm(linthresh=10.0, linscale=0.03, vmin=global_min, vmax=global_max)\n", + "\n", + "# Prepare axes: one row per group, share x among axes for common x-label\n", + "fig, axs = plt.subplots(nrows=n_groups, ncols=1, figsize=(10, 2.2 * n_groups), sharex=True)\n", + "if n_groups == 1:\n", + " axs = [axs]\n", + "\n", + "# Second pass: draw each subplot (no per-axes colorbar)\n", + "ims = []\n", + "for i, (ax, cached) in enumerate(zip(axs, flattened_cache)):\n", + " txrx, group_df, flat_df, meas_cols, plot_data = cached\n", + " # Draw image without adding colorbar (shared colorbar below)\n", + " im = imshow_with_time(ax, plot_data, index=group_df.index, cmap='viridis', interpolation='hamming', norm=norm, num_ticks=10, add_colorbar=False)\n", + " ims.append(im)\n", + "\n", + " # Title with config details if available\n", + " try:\n", + " cfg = config.tx_rx_config[txrx]\n", + " title_extra = f\" TX {cfg.tx_channels} -> RX {cfg.rx_channels}\"\n", + " except Exception:\n", + " title_extra = ''\n", + " ax.set_title(f'tx_rx_id: {txrx} (n={len(group_df)})' + title_extra)\n", + "\n", + " # Y ticks\n", + " n_samples = plot_data.shape[0]\n", + " sample_ticks = np.linspace(0, n_samples - 1, min(6, max(1, n_samples)), dtype=int)\n", + " ax.set_yticks(sample_ticks)\n", + " ax.set_yticklabels([str(t) for t in sample_ticks])\n", + "\n", + " # Hide x tick labels for all but the bottom axis (since sharex=True)\n", + " if i < n_groups - 1:\n", + " ax.tick_params(axis='x', labelbottom=False)\n", + " ax.set_ylabel('Sample index')\n", + "\n", + "# Shared colorbar across all subplots at bottom, horizontal\n", + "# cbar = fig.colorbar(ims[0],\n", + "# ax=axs,\n", + "# location='bottom',\n", + "# orientation='horizontal',\n", + "# fraction=0.06,\n", + "# pad=0.12,\n", + "# shrink=0.7,\n", + "# anchor=((0.5, 1.0)))\n", + "# cbar.set_label('ADC digital code')\n", + "\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bfb60913", + "metadata": {}, + "outputs": [], + "source": [ + "# Plot with real time on x-axis from DataFrame index (helper-based)\n", + "fig, ax = plt.subplots(figsize=(12, 6))\n", + "border = 1000\n", + "\n", + "# Select a single RX channel (0) and flatten to measurement columns\n", + "df_channel_sel = df[df['rx'] == 0]\n", + "flattened_df, meas_cols = flatten_df_measurements(df_channel_sel, sample_crop=SAMPLE_CROP)\n", + "\n", + "# Build (n_acq, n_samples) and transpose to (n_samples, n_acq) for imshow\n", + "values = flattened_df[meas_cols].to_numpy(dtype=float)\n", + "plot_data = values.T\n", + "\n", + "# Choose a robust norm (symmetric range around 0 for visibility)\n", + "norm = colors.SymLogNorm(linthresh=10, linscale=0.03, vmin=-border, vmax=border)\n", + "\n", + "# Plot using helper (adds colorbar and local-time xticks)\n", + "imshow_with_time(ax, plot_data, index=df_channel_sel.index, cmap='viridis', interpolation='hamming', norm=norm, num_ticks=10)\n", + "\n", + "ax.set_title('Measurement evolution over time (RX 0)')\n", + "ax.set_xlabel('Time')\n", + "ax.set_ylabel('Sample index within measurement (distance of reflection)')\n", + "ax.grid(False)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9df5847e", + "metadata": {}, + "outputs": [], + "source": [ + "measurements = df['measurement'].dropna()\n", + "# Create interactive 3D plot\n", + "fig = plt.figure(figsize=(14, 10))\n", + "border = 1000\n", + "\n", + "# Compute max length of measurements to handle varying lengths\n", + "max_len = max(len(m) if hasattr(m, '__len__') else 1 for m in measurements)\n", + "\n", + "df_channel_sel = df[df['rx'] == 0]\n", + "padded = np.array([np.array(m, dtype=float)\n", + " for m in df_channel_sel['measurement']])\n", + "padded = padded[:, :100]\n", + "\n", + "# Create result DataFrame with proper indexing\n", + "measurement_df = pd.DataFrame(padded, index=df_channel_sel.index)\n", + "result = pd.concat(\n", + " [df.drop(columns=['measurement']).loc[df_channel_sel.index], measurement_df], axis=1)\n", + "\n", + "# Create 3D surface plot\n", + "ax = fig.add_subplot(111, projection='3d')\n", + "\n", + "# Create coordinate grids for 3D plotting\n", + "X = np.arange(padded.shape[0]) # Time (measurement index)\n", + "Y = np.arange(padded.shape[1]) # Sample index within measurement\n", + "X, Y = np.meshgrid(X, Y)\n", + "\n", + "# Transpose padded data to match meshgrid orientation\n", + "Z = padded.T\n", + "\n", + "# Create the 3D surface plot\n", + "surf = ax.plot_surface(X, Y, Z, \n", + " cmap='viridis', \n", + " alpha=0.8,\n", + " linewidth=0,\n", + " antialiased=True,\n", + " vmin=-border, \n", + " vmax=border)\n", + "\n", + "# Add a color bar\n", + "cbar = fig.colorbar(surf, ax=ax, shrink=0.5, aspect=20)\n", + "cbar.set_label('ADC digital code', rotation=270, labelpad=15)\n", + "\n", + "# Set labels and title\n", + "ax.set_xlabel('Time')\n", + "ax.set_ylabel('Sample index within measurement (distance of reflection)')\n", + "ax.set_zlabel('ADC digital code')\n", + "ax.set_title('3D Interactive Measurement Evolution Over Time')\n", + "\n", + "# Set viewing angle for better initial perspective\n", + "ax.view_init(elev=30, azim=45)\n", + "\n", + "# Set z-axis limits for better visualization\n", + "ax.set_zlim(-border, border)\n", + "\n", + "# Enable interactive rotation and zooming\n", + "plt.tight_layout()\n", + "plt.show()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sw/wulpus-frontend/.gitignore b/sw/wulpus-frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/sw/wulpus-frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/sw/wulpus-frontend/README.md b/sw/wulpus-frontend/README.md new file mode 100644 index 0000000..f2865be --- /dev/null +++ b/sw/wulpus-frontend/README.md @@ -0,0 +1,71 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +- using google symbols: https://fonts.google.com/icons \ No newline at end of file diff --git a/sw/wulpus-frontend/build.py b/sw/wulpus-frontend/build.py new file mode 100644 index 0000000..011c479 --- /dev/null +++ b/sw/wulpus-frontend/build.py @@ -0,0 +1,34 @@ +import os +import shutil + +if __name__ == "__main__": + currentPath = os.path.dirname(os.path.abspath(__file__)) + destPath = os.path.abspath(os.path.join( + currentPath, '..', 'wulpus', 'production-frontend')) + os.chdir(currentPath) + + print("Starting build process...") + build_result = os.system("npm run build") + + if build_result != 0: + print( + f"Build failed with exit code {build_result}. Aborting copy operation.") + exit(build_result) + + print("Build succeeded. Proceeding to copy files...") + + # Clearing the destination path if it exists, preserving .gitignore + if os.path.exists(destPath): + print(f"Clearing destination directory: {destPath}") + for item in os.listdir(destPath): + if item != '.gitignore': + item_path = os.path.join(destPath, item) + if os.path.isdir(item_path): + shutil.rmtree(item_path) + else: + os.remove(item_path) + + print("Copying built files to production directory...") + shutil.copytree("dist", destPath, dirs_exist_ok=True) + print(f"Successfully copied files to {destPath}") + print("Successfully Finished!") diff --git a/sw/wulpus-frontend/eslint.config.js b/sw/wulpus-frontend/eslint.config.js new file mode 100644 index 0000000..a67a3b5 --- /dev/null +++ b/sw/wulpus-frontend/eslint.config.js @@ -0,0 +1,27 @@ +import js from "@eslint/js" +import globals from "globals" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import tseslint from "typescript-eslint" +import { globalIgnores } from "eslint/config" + +export default tseslint.config([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs["recommended-latest"], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["off", { argsIgnorePattern: "^_" }], + }, + }, +]) diff --git a/sw/wulpus-frontend/index.html b/sw/wulpus-frontend/index.html new file mode 100644 index 0000000..229c509 --- /dev/null +++ b/sw/wulpus-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + WULPUS + + +
+ + + diff --git a/sw/wulpus-frontend/package-lock.json b/sw/wulpus-frontend/package-lock.json new file mode 100644 index 0000000..dff4f63 --- /dev/null +++ b/sw/wulpus-frontend/package-lock.json @@ -0,0 +1,6372 @@ +{ + "name": "wulpus-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wulpus-frontend", + "version": "0.0.0", + "dependencies": { + "@material-symbols/font-200": "^0.35.2", + "@material-symbols/font-300": "^0.35.2", + "@material-symbols/font-400": "^0.34.1", + "@tailwindcss/vite": "^4.1.12", + "@tanstack/react-query": "^5.90.2", + "plotly.js": "^3.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", + "react-plotly.js": "^2.6.0", + "react-range-slider-input": "^3.2.1", + "react-router": "^7.8.2", + "react-use-websocket": "^4.13.0", + "tailwindcss": "^4.1.12" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/plotly.js": "^3.0.3", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@types/react-plotly.js": "^2.6.3", + "@vitejs/plugin-react-swc": "^4.0.0", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2" + } + }, + "node_modules/@choojs/findup": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", + "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", + "license": "MIT", + "dependencies": { + "commander": "^2.15.1" + }, + "bin": { + "findup": "bin/findup.js" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC" + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/@material-symbols/font-200": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@material-symbols/font-200/-/font-200-0.35.2.tgz", + "integrity": "sha512-jliR0Joldkke8t4o5hdU/EMn+MRfm/VCZL1Sw821rRooezse3muSYbtQauCiJ/+gv9Fge8U2tnQUpul1QziQ1Q==", + "license": "Apache-2.0" + }, + "node_modules/@material-symbols/font-300": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@material-symbols/font-300/-/font-300-0.35.2.tgz", + "integrity": "sha512-jd/rUrT5EhCohves+BpZJoTbKHqBQkNz7ckSeISnMDccwknHLt9PxNdaF5KpViDPLA08FKsGPyl+s+MhrM26Tg==", + "license": "Apache-2.0" + }, + "node_modules/@material-symbols/font-400": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@material-symbols/font-400/-/font-400-0.34.1.tgz", + "integrity": "sha512-0z4VVcupP3Y0XAOs+xHCy9EsGKGTk8lD0p+9zx5kwdppKwFeq84dj0GNtT5uCRqZOPZ2zgYgXHJzuKQ0q1iIXw==", + "license": "Apache-2.0" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@plotly/d3": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", + "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", + "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1", + "d3-collection": "1", + "d3-shape": "^1.2.0" + } + }, + "node_modules/@plotly/d3-sankey-circular": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", + "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", + "license": "MIT", + "dependencies": { + "d3-array": "^1.2.1", + "d3-collection": "^1.0.4", + "d3-shape": "^1.2.0", + "elementary-circuits-directed-graph": "^1.0.4" + } + }, + "node_modules/@plotly/mapbox-gl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", + "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/@plotly/point-cluster": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", + "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "binary-search-bounds": "^2.0.4", + "clamp": "^1.0.1", + "defined": "^1.0.0", + "dtype": "^2.0.0", + "flatten-vertex-data": "^1.0.2", + "is-obj": "^1.0.1", + "math-log2": "^1.0.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/@plotly/regl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", + "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.32", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", + "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.47.1.tgz", + "integrity": "sha512-lTahKRJip0knffA/GTNFJMrToD+CM+JJ+Qt5kjzBK/sFQ0EWqfKW3AYQSlZXN98tX0lx66083U9JYIMioMMK7g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.47.1.tgz", + "integrity": "sha512-uqxkb3RJLzlBbh/bbNQ4r7YpSZnjgMgyoEOY7Fy6GCbelkDSAzeiogxMG9TfLsBbqmGsdDObo3mzGqa8hps4MA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.47.1.tgz", + "integrity": "sha512-tV6reObmxBDS4DDyLzTDIpymthNlxrLBGAoQx6m2a7eifSNEZdkXQl1PE4ZjCkEDPVgNXSzND/k9AQ3mC4IOEQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.47.1.tgz", + "integrity": "sha512-XuJRPTnMk1lwsSnS3vYyVMu4x/+WIw1MMSiqj5C4j3QOWsMzbJEK90zG+SWV1h0B1ABGCQ0UZUjti+TQK35uHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.47.1.tgz", + "integrity": "sha512-79BAm8Ag/tmJ5asCqgOXsb3WY28Rdd5Lxj8ONiQzWzy9LvWORd5qVuOnjlqiWWZJw+dWewEktZb5yiM1DLLaHw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.47.1.tgz", + "integrity": "sha512-OQ2/ZDGzdOOlyfqBiip0ZX/jVFekzYrGtUsqAfLDbWy0jh1PUU18+jYp8UMpqhly5ltEqotc2miLngf9FPSWIA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.47.1.tgz", + "integrity": "sha512-HZZBXJL1udxlCVvoVadstgiU26seKkHbbAMLg7680gAcMnRNP9SAwTMVet02ANA94kXEI2VhBnXs4e5nf7KG2A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.47.1.tgz", + "integrity": "sha512-sZ5p2I9UA7T950JmuZ3pgdKA6+RTBr+0FpK427ExW0t7n+QwYOcmDTK/aRlzoBrWyTpJNlS3kacgSlSTUg6P/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.47.1.tgz", + "integrity": "sha512-3hBFoqPyU89Dyf1mQRXCdpc6qC6At3LV6jbbIOZd72jcx7xNk3aAp+EjzAtN6sDlmHFzsDJN5yeUySvorWeRXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.47.1.tgz", + "integrity": "sha512-49J4FnMHfGodJWPw73Ve+/hsPjZgcXQGkmqBGZFvltzBKRS+cvMiWNLadOMXKGnYRhs1ToTGM0sItKISoSGUNA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.47.1.tgz", + "integrity": "sha512-4yYU8p7AneEpQkRX03pbpLmE21z5JNys16F1BZBZg5fP9rIlb0TkeQjn5du5w4agConCCEoYIG57sNxjryHEGg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.47.1.tgz", + "integrity": "sha512-fAiq+J28l2YMWgC39jz/zPi2jqc0y3GSRo1yyxlBHt6UN0yYgnegHSRPa3pnHS5amT/efXQrm0ug5+aNEu9UuQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.47.1.tgz", + "integrity": "sha512-daoT0PMENNdjVYYU9xec30Y2prb1AbEIbb64sqkcQcSaR0zYuKkoPuhIztfxuqN82KYCKKrj+tQe4Gi7OSm1ow==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.47.1.tgz", + "integrity": "sha512-JNyXaAhWtdzfXu5pUcHAuNwGQKevR+6z/poYQKVW+pLaYOj9G1meYc57/1Xv2u4uTxfu9qEWmNTjv/H/EpAisw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.47.1.tgz", + "integrity": "sha512-U/CHbqKSwEQyZXjCpY43/GLYcTVKEXeRHw0rMBJP7fP3x6WpYG4LTJWR3ic6TeYKX6ZK7mrhltP4ppolyVhLVQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.47.1.tgz", + "integrity": "sha512-uTLEakjxOTElfeZIGWkC34u2auLHB1AYS6wBjPGI00bWdxdLcCzK5awjs25YXpqB9lS8S0vbO0t9ZcBeNibA7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.47.1.tgz", + "integrity": "sha512-Ft+d/9DXs30BK7CHCTX11FtQGHUdpNDLJW0HHLign4lgMgBcPFN3NkdIXhC5r9iwsMwYreBBc4Rho5ieOmKNVQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.47.1.tgz", + "integrity": "sha512-N9X5WqGYzZnjGAFsKSfYFtAShYjwOmFJoWbLg3dYixZOZqU7hdMq+/xyS14zKLhFhZDhP9VfkzQnsdk0ZDS9IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.47.1.tgz", + "integrity": "sha512-O+KcfeCORZADEY8oQJk4HK8wtEOCRE4MdOkb8qGZQNun3jzmj2nmhV/B/ZaaZOkPmJyvm/gW9n0gsB4eRa1eiQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.47.1.tgz", + "integrity": "sha512-CpKnYa8eHthJa3c+C38v/E+/KZyF1Jdh2Cz3DyKZqEWYgrM1IHFArXNWvBLPQCKUEsAqqKX27tTqVEFbDNUcOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.4.tgz", + "integrity": "sha512-bCq2GCuKV16DSOOEdaRqHMm1Ok4YEoLoNdgdzp8BS/Hxxr/0NVCHBUgRLLRy/TlJGv20Idx+djd5FIDvsnqMaw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.24" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.13.4", + "@swc/core-darwin-x64": "1.13.4", + "@swc/core-linux-arm-gnueabihf": "1.13.4", + "@swc/core-linux-arm64-gnu": "1.13.4", + "@swc/core-linux-arm64-musl": "1.13.4", + "@swc/core-linux-x64-gnu": "1.13.4", + "@swc/core-linux-x64-musl": "1.13.4", + "@swc/core-win32-arm64-msvc": "1.13.4", + "@swc/core-win32-ia32-msvc": "1.13.4", + "@swc/core-win32-x64-msvc": "1.13.4" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.4.tgz", + "integrity": "sha512-CGbTu9dGBwgklUj+NAQAYyPjBuoHaNRWK4QXJRv1QNIkhtE27aY7QA9uEON14SODxsio3t8+Pjjl2Mzx1Pxf+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.4.tgz", + "integrity": "sha512-qLFwYmLrqHNCf+JO9YLJT6IP/f9LfbXILTaqyfluFLW1GCfJyvUrSt3CWaL2lwwyT1EbBh6BVaAAecXiJIo3vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.4.tgz", + "integrity": "sha512-y7SeNIA9em3+smNMpr781idKuNwJNAqewiotv+pIR5FpXdXXNjHWW+jORbqQYd61k6YirA5WQv+Af4UzqEX17g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.4.tgz", + "integrity": "sha512-u0c51VdzRmXaphLgghY9+B2Frzler6nIv+J788nqIh6I0ah3MmMW8LTJKZfdaJa3oFxzGNKXsJiaU2OFexNkug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.4.tgz", + "integrity": "sha512-Z92GJ98x8yQHn4I/NPqwAQyHNkkMslrccNVgFcnY1msrb6iGSw5uFg2H2YpvQ5u2/Yt6CRpLIUVVh8SGg1+gFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.4.tgz", + "integrity": "sha512-rSUcxgpFF0L8Fk1CbUf946XCX1CRp6eaHfKqplqFNWCHv8HyqAtSFvgCHhT+bXru6Ca/p3sLC775SUeSWhsJ9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.4.tgz", + "integrity": "sha512-qY77eFUvmdXNSmTW+I1fsz4enDuB0I2fE7gy6l9O4koSfjcCxkXw2X8x0lmKLm3FRiINS1XvZSg2G+q4NNQCRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.4.tgz", + "integrity": "sha512-xjPeDrOf6elCokxuyxwoskM00JJFQMTT2hTQZE24okjG3JiXzSFV+TmzYSp+LWNxPpnufnUUy/9Ee8+AcpslGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.4.tgz", + "integrity": "sha512-Ta+Bblc9tE9X9vQlpa3r3+mVnHYdKn09QsZ6qQHvuXGKWSS99DiyxKTYX2vxwMuoTObR0BHvnhNbaGZSV1VwNA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.4.tgz", + "integrity": "sha512-pHnb4QwGiuWs4Z9ePSgJ48HP3NZIno6l75SB8YLCiPVDiLhvCLKEjz/caPRsFsmet9BEP8e3bAf2MV8MXgaTSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.24", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.24.tgz", + "integrity": "sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", + "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@turf/area": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.2.0.tgz", + "integrity": "sha512-zuTTdQ4eoTI9nSSjerIy4QwgvxqwJVciQJ8tOPuMHbXJ9N/dNjI7bU8tasjhxas/Cx3NE9NxVHtNpYHL0FSzoA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@turf/meta": "^7.2.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.2.0.tgz", + "integrity": "sha512-wzHEjCXlYZiDludDbXkpBSmv8Zu6tPGLmJ1sXQ6qDwpLE1Ew3mcWqt8AaxfTP5QwDNQa3sf2vvgTEzNbPQkCiA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@turf/meta": "^7.2.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.2.0.tgz", + "integrity": "sha512-yJqDSw25T7P48au5KjvYqbDVZ7qVnipziVfZ9aSo7P2/jTE7d4BP21w0/XLi3T/9bry/t9PR1GDDDQljN4KfDw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@turf/meta": "^7.2.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", + "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.2.0.tgz", + "integrity": "sha512-igzTdHsQc8TV1RhPuOLVo74Px/hyPrVgVOTgjWQZzt3J9BVseCdpfY/0cJBdlSRI4S/yTmmHl7gAqjhpYH5Yaw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "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==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "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/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "node_modules/@types/plotly.js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-3.0.3.tgz", + "integrity": "sha512-9CENH8hh2diOML3o4lEd4H0nwQ4uECEE9mZQc+zriGEdd0zK8ru75t7qFhaMQmiWFFPGWqI4FpodBZFTmWpdbQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", + "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-plotly.js": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.3.tgz", + "integrity": "sha512-HBQwyGuu/dGXDsWhnQrhH+xcJSsHvjkwfSRjP+YpOsCCWryIuXF78ZCBjpfgO3sCc0Jo8sYp4NOGtqT7Cn3epQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/plotly.js": "*", + "@types/react": "*" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", + "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/type-utils": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.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.40.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz", + "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4" + }, + "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", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", + "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.40.0", + "@typescript-eslint/types": "^8.40.0", + "debug": "^4.3.4" + }, + "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.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz", + "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0" + }, + "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.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", + "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz", + "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.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", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", + "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "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.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", + "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.40.0", + "@typescript-eslint/tsconfig-utils": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.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.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", + "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.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", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", + "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "eslint-visitor-keys": "^4.2.1" + }, + "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-swc": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.0.1.tgz", + "integrity": "sha512-NQhPjysi5duItyrMd5JWZFf2vNOuSMyw+EoZyTBDzk+DkfYD8WNrsUs09sELV2cr1P15nufsN25hsUBt4CKF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.32", + "@swc/core": "^1.13.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "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.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-bounds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", + "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==", + "license": "MIT" + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-normalize": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", + "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.0" + } + }, + "node_modules/array-range": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", + "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==", + "license": "MIT" + }, + "node_modules/array-rearrange": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-rearrange/-/array-rearrange-2.2.2.tgz", + "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", + "license": "MIT" + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/canvas-fit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz", + "integrity": "sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==", + "license": "MIT", + "dependencies": { + "element-size": "^1.1.1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-alpha": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.0.4.tgz", + "integrity": "sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.3.8" + } + }, + "node_modules/color-alpha/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", + "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-normalize": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", + "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-rgba": "^2.1.1", + "dtype": "^2.0.0" + } + }, + "node_modules/color-normalize/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-normalize/node_modules/color-rgba": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.4.0.tgz", + "integrity": "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.4.2", + "color-space": "^2.0.0" + } + }, + "node_modules/color-parse": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.0.tgz", + "integrity": "sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-rgba": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-3.0.0.tgz", + "integrity": "sha512-PPwZYkEY3M2THEHHV6Y95sGUie77S7X8v+h1r6LSAPF3/LL2xJ8duUXSrkic31Nzc4odPwHgUbiX/XuTYzQHQg==", + "license": "MIT", + "dependencies": { + "color-parse": "^2.0.0", + "color-space": "^2.0.0" + } + }, + "node_modules/color-space": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/color-space/-/color-space-2.3.2.tgz", + "integrity": "sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==", + "license": "Unlicense" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/core-js": { + "version": "3.45.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz", + "integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/country-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", + "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", + "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-font": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", + "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", + "license": "MIT", + "dependencies": { + "css-font-size-keywords": "^1.0.0", + "css-font-stretch-keywords": "^1.0.1", + "css-font-style-keywords": "^1.0.1", + "css-font-weight-keywords": "^1.0.0", + "css-global-keywords": "^1.0.1", + "css-system-font-keywords": "^1.0.0", + "pick-by-alias": "^1.2.0", + "string-split-by": "^1.0.0", + "unquote": "^1.1.0" + } + }, + "node_modules/css-font-size-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", + "license": "MIT" + }, + "node_modules/css-font-stretch-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", + "license": "MIT" + }, + "node_modules/css-font-style-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", + "license": "MIT" + }, + "node_modules/css-font-weight-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", + "license": "MIT" + }, + "node_modules/css-global-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", + "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", + "license": "MIT" + }, + "node_modules/css-system-font-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", + "license": "MIT" + }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/d3-geo-projection": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", + "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "2", + "d3-array": "1", + "d3-geo": "^1.12.0", + "resolve": "^1.1.10" + }, + "bin": { + "geo2svg": "bin/geo2svg", + "geograticule": "bin/geograticule", + "geoproject": "bin/geoproject", + "geoquantize": "bin/geoquantize", + "geostitch": "bin/geostitch" + } + }, + "node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1" + } + }, + "node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "license": "BSD-3-Clause" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-kerning": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", + "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/draw-svg-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", + "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "~0.1.1", + "normalize-svg-path": "~0.1.0" + } + }, + "node_modules/dtype": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", + "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/dup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", + "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==", + "license": "MIT" + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/element-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz", + "integrity": "sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==", + "license": "MIT" + }, + "node_modules/elementary-circuits-directed-graph": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", + "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", + "license": "MIT", + "dependencies": { + "strongly-connected-components": "^1.0.1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "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/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "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", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.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==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/falafel": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", + "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "isarray": "^2.0.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/falafel/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.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-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-isnumeric": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", + "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", + "license": "MIT", + "dependencies": { + "is-string-blank": "^1.0.1" + } + }, + "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/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/flatten-vertex-data": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", + "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", + "license": "MIT", + "dependencies": { + "dtype": "^2.0.0" + } + }, + "node_modules/font-atlas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", + "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", + "license": "MIT", + "dependencies": { + "css-font": "^1.0.0" + } + }, + "node_modules/font-measure": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", + "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", + "license": "MIT", + "dependencies": { + "css-font": "^1.2.0" + } + }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/get-canvas-context": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", + "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", + "license": "MIT" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gl-mat4": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", + "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==", + "license": "Zlib" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gl-text": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", + "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.2", + "color-normalize": "^1.5.0", + "css-font": "^1.2.0", + "detect-kerning": "^2.1.2", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "font-atlas": "^2.1.0", + "font-measure": "^1.2.2", + "gl-util": "^3.1.2", + "is-plain-obj": "^1.1.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "parse-unit": "^1.0.1", + "pick-by-alias": "^1.2.0", + "regl": "^2.0.0", + "to-px": "^1.0.1", + "typedarray-pool": "^1.1.0" + } + }, + "node_modules/gl-util": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", + "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1", + "is-firefox": "^1.0.3", + "is-plain-obj": "^1.1.0", + "number-is-integer": "^1.0.1", + "object-assign": "^4.1.0", + "pick-by-alias": "^1.2.0", + "weak-map": "^1.0.5" + } + }, + "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/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-inject-defines": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", + "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", + "license": "MIT", + "dependencies": { + "glsl-token-inject-block": "^1.0.0", + "glsl-token-string": "^1.0.1", + "glsl-tokenizer": "^2.0.2" + } + }, + "node_modules/glsl-resolve": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", + "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", + "license": "MIT", + "dependencies": { + "resolve": "^0.6.1", + "xtend": "^2.1.2" + } + }, + "node_modules/glsl-resolve/node_modules/resolve": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", + "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==", + "license": "MIT" + }, + "node_modules/glsl-resolve/node_modules/xtend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/glsl-token-assignments": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", + "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==", + "license": "MIT" + }, + "node_modules/glsl-token-defines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", + "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", + "license": "MIT", + "dependencies": { + "glsl-tokenizer": "^2.0.0" + } + }, + "node_modules/glsl-token-depth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", + "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==", + "license": "MIT" + }, + "node_modules/glsl-token-descope": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", + "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", + "license": "MIT", + "dependencies": { + "glsl-token-assignments": "^2.0.0", + "glsl-token-depth": "^1.1.0", + "glsl-token-properties": "^1.0.0", + "glsl-token-scope": "^1.1.0" + } + }, + "node_modules/glsl-token-inject-block": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", + "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==", + "license": "MIT" + }, + "node_modules/glsl-token-properties": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", + "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==", + "license": "MIT" + }, + "node_modules/glsl-token-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", + "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==", + "license": "MIT" + }, + "node_modules/glsl-token-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", + "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==", + "license": "MIT" + }, + "node_modules/glsl-token-whitespace-trim": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", + "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", + "license": "MIT", + "dependencies": { + "through2": "^0.6.3" + } + }, + "node_modules/glsl-tokenizer/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/glsl-tokenizer/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/glslify": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", + "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", + "license": "MIT", + "dependencies": { + "bl": "^2.2.1", + "concat-stream": "^1.5.2", + "duplexify": "^3.4.5", + "falafel": "^2.1.0", + "from2": "^2.3.0", + "glsl-resolve": "0.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glslify-bundle": "^5.0.0", + "glslify-deps": "^1.2.5", + "minimist": "^1.2.5", + "resolve": "^1.1.5", + "stack-trace": "0.0.9", + "static-eval": "^2.0.5", + "through2": "^2.0.1", + "xtend": "^4.0.0" + }, + "bin": { + "glslify": "bin.js" + } + }, + "node_modules/glslify-bundle": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", + "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", + "license": "MIT", + "dependencies": { + "glsl-inject-defines": "^1.0.1", + "glsl-token-defines": "^1.0.0", + "glsl-token-depth": "^1.1.1", + "glsl-token-descope": "^1.0.2", + "glsl-token-scope": "^1.1.1", + "glsl-token-string": "^1.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glsl-tokenizer": "^2.0.2", + "murmurhash-js": "^1.0.0", + "shallow-copy": "0.0.1" + } + }, + "node_modules/glslify-deps": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", + "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", + "license": "ISC", + "dependencies": { + "@choojs/findup": "^0.2.0", + "events": "^3.2.0", + "glsl-resolve": "0.0.1", + "glsl-tokenizer": "^2.0.0", + "graceful-fs": "^4.1.2", + "inherits": "^2.0.1", + "map-limit": "0.0.1", + "resolve": "^1.0.0" + } + }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-hover": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", + "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-passive-events": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", + "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", + "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-firefox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", + "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", + "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-iexplorer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", + "integrity": "sha512-YeLzceuwg3K6O0MLM3UyUUjKAlyULetwryFp1mHy1I5PfArK0AEqlfa+MR4gkJjcbuJXoDJCvXbyqZVf5CR2Sg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-mobile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-string-blank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==", + "license": "MIT" + }, + "node_modules/is-svg-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", + "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "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/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "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/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "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-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "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.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "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/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "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/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/map-limit/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/maplibre-gl/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/math-log2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", + "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mouse-change": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mouse-change/-/mouse-change-1.4.0.tgz", + "integrity": "sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==", + "license": "MIT", + "dependencies": { + "mouse-event": "^1.0.0" + } + }, + "node_modules/mouse-event": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mouse-event/-/mouse-event-1.0.5.tgz", + "integrity": "sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==", + "license": "MIT" + }, + "node_modules/mouse-event-offset": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", + "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==", + "license": "MIT" + }, + "node_modules/mouse-wheel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mouse-wheel/-/mouse-wheel-1.2.0.tgz", + "integrity": "sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==", + "license": "MIT", + "dependencies": { + "right-now": "^1.0.0", + "signum": "^1.0.0", + "to-px": "^1.0.1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" + }, + "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/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/normalize-svg-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", + "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==", + "license": "MIT" + }, + "node_modules/number-is-integer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", + "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", + "license": "MIT", + "dependencies": { + "is-finite": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "license": "MIT" + }, + "node_modules/parse-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", + "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", + "license": "MIT", + "dependencies": { + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/parse-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", + "license": "MIT" + }, + "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/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/pick-by-alias": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", + "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plotly.js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-3.1.0.tgz", + "integrity": "sha512-vx+CyzApL9tquFpwoPHOGSIWDbFPsA4om/tXZcnsygGUejXideDF9R5VwkltEIDG7Xuof45quVPyz1otv6Aqjw==", + "license": "MIT", + "dependencies": { + "@plotly/d3": "3.8.2", + "@plotly/d3-sankey": "0.7.2", + "@plotly/d3-sankey-circular": "0.33.1", + "@plotly/mapbox-gl": "1.13.4", + "@plotly/regl": "^2.1.2", + "@turf/area": "^7.1.0", + "@turf/bbox": "^7.1.0", + "@turf/centroid": "^7.1.0", + "base64-arraybuffer": "^1.0.2", + "canvas-fit": "^1.5.0", + "color-alpha": "1.0.4", + "color-normalize": "1.5.0", + "color-parse": "2.0.0", + "color-rgba": "3.0.0", + "country-regex": "^1.1.0", + "d3-force": "^1.2.1", + "d3-format": "^1.4.5", + "d3-geo": "^1.12.1", + "d3-geo-projection": "^2.9.0", + "d3-hierarchy": "^1.1.9", + "d3-interpolate": "^3.0.1", + "d3-time": "^1.1.0", + "d3-time-format": "^2.2.3", + "fast-isnumeric": "^1.1.4", + "gl-mat4": "^1.2.0", + "gl-text": "^1.4.0", + "has-hover": "^1.0.1", + "has-passive-events": "^1.0.0", + "is-mobile": "^4.0.0", + "maplibre-gl": "^4.7.1", + "mouse-change": "^1.4.0", + "mouse-event-offset": "^3.0.2", + "mouse-wheel": "^1.2.0", + "native-promise-only": "^0.8.1", + "parse-svg-path": "^0.1.2", + "point-in-polygon": "^1.1.0", + "polybooljs": "^1.2.2", + "probe-image-size": "^7.2.3", + "regl-error2d": "^2.0.12", + "regl-line2d": "^3.1.3", + "regl-scatter2d": "^3.3.1", + "regl-splom": "^1.0.14", + "strongly-connected-components": "^1.0.1", + "superscript-text": "^1.0.0", + "svg-path-sdf": "^1.1.3", + "tinycolor2": "^1.4.2", + "to-px": "1.0.1", + "topojson-client": "^3.1.0", + "webgl-context": "^2.2.0", + "world-calendars": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/polybooljs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", + "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "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/probe-image-size": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", + "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==", + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "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/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-plotly.js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", + "integrity": "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "plotly.js": ">1.34.0", + "react": ">0.13.0" + } + }, + "node_modules/react-range-slider-input": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.2.1.tgz", + "integrity": "sha512-MApTX4a/Uzm9ZrJFoWu2kM87TKHJpnHHBV2JR9N9WmOSWk832DDBT3suNN6vCQmFRRtfJO9stw+bsiGpto0Hig==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "core-js": "^3.22.4" + } + }, + "node_modules/react-router": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", + "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-use-websocket": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.13.0.tgz", + "integrity": "sha512-anMuVoV//g2N76Wxqvqjjo1X48r9Np3y1/gMl7arX84tAPXdy5R7sB5lO5hvCzQRYjqXwV8XMAiEBOUbyrZFrw==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/regl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz", + "integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==", + "license": "MIT" + }, + "node_modules/regl-error2d": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", + "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-line2d": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", + "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-find-index": "^1.0.2", + "array-normalize": "^1.1.4", + "color-normalize": "^1.5.0", + "earcut": "^2.1.5", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0" + } + }, + "node_modules/regl-scatter2d": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.3.1.tgz", + "integrity": "sha512-seOmMIVwaCwemSYz/y4WE0dbSO9svNFSqtTh5RE57I7PjGo3tcUYKtH0MTSoshcAsreoqN8HoCtnn8wfHXXfKQ==", + "license": "MIT", + "dependencies": { + "@plotly/point-cluster": "^3.1.9", + "array-range": "^1.0.1", + "array-rearrange": "^2.2.2", + "clamp": "^1.0.1", + "color-id": "^1.1.0", + "color-normalize": "^1.5.0", + "color-rgba": "^2.1.1", + "flatten-vertex-data": "^1.0.2", + "glslify": "^7.0.0", + "is-iexplorer": "^1.0.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-scatter2d/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/regl-scatter2d/node_modules/color-rgba": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.4.0.tgz", + "integrity": "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.4.2", + "color-space": "^2.0.0" + } + }, + "node_modules/regl-splom": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", + "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-range": "^1.0.1", + "color-alpha": "^1.0.4", + "flatten-vertex-data": "^1.0.2", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "raf": "^3.4.1", + "regl-scatter2d": "^3.2.3" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/right-now": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", + "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.47.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.47.1.tgz", + "integrity": "sha512-iasGAQoZ5dWDzULEUX3jiW0oB1qyFOepSyDyoU6S/OhVlDIwj5knI5QBa5RRQ0sK7OE0v+8VIi2JuV+G+3tfNg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.47.1", + "@rollup/rollup-android-arm64": "4.47.1", + "@rollup/rollup-darwin-arm64": "4.47.1", + "@rollup/rollup-darwin-x64": "4.47.1", + "@rollup/rollup-freebsd-arm64": "4.47.1", + "@rollup/rollup-freebsd-x64": "4.47.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.47.1", + "@rollup/rollup-linux-arm-musleabihf": "4.47.1", + "@rollup/rollup-linux-arm64-gnu": "4.47.1", + "@rollup/rollup-linux-arm64-musl": "4.47.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.47.1", + "@rollup/rollup-linux-ppc64-gnu": "4.47.1", + "@rollup/rollup-linux-riscv64-gnu": "4.47.1", + "@rollup/rollup-linux-riscv64-musl": "4.47.1", + "@rollup/rollup-linux-s390x-gnu": "4.47.1", + "@rollup/rollup-linux-x64-gnu": "4.47.1", + "@rollup/rollup-linux-x64-musl": "4.47.1", + "@rollup/rollup-win32-arm64-msvc": "4.47.1", + "@rollup/rollup-win32-ia32-msvc": "4.47.1", + "@rollup/rollup-win32-x64-msvc": "4.47.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", + "license": "MIT" + }, + "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/signum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", + "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", + "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", + "engines": { + "node": "*" + } + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "license": "MIT", + "dependencies": { + "parenthesis": "^3.1.5" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strongly-connected-components": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", + "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==", + "license": "MIT" + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/supercluster/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/superscript-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/superscript-text/-/superscript-text-1.0.0.tgz", + "integrity": "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, + "node_modules/svg-path-bounds": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", + "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "^0.1.1", + "is-svg-path": "^1.0.1", + "normalize-svg-path": "^1.0.0", + "parse-svg-path": "^0.1.2" + } + }, + "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, + "node_modules/svg-path-sdf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", + "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", + "license": "MIT", + "dependencies": { + "bitmap-sdf": "^1.0.0", + "draw-svg-path": "^1.0.0", + "is-svg-path": "^1.0.1", + "parse-svg-path": "^0.1.2", + "svg-path-bounds": "^1.0.1" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/to-float32": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", + "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==", + "license": "MIT" + }, + "node_modules/to-px": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.0.1.tgz", + "integrity": "sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==", + "license": "MIT", + "dependencies": { + "parse-unit": "^1.0.1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "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==", + "license": "0BSD" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "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/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.40.0.tgz", + "integrity": "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.40.0", + "@typescript-eslint/parser": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.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", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "license": "MIT" + }, + "node_modules/update-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", + "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==", + "license": "MIT" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", + "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.14" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/weak-map": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", + "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", + "license": "Apache-2.0" + }, + "node_modules/webgl-context": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", + "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", + "license": "MIT", + "dependencies": { + "get-canvas-context": "^1.0.1" + } + }, + "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/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/world-calendars": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "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" + } + } + } +} diff --git a/sw/wulpus-frontend/package.json b/sw/wulpus-frontend/package.json new file mode 100644 index 0000000..cb69b96 --- /dev/null +++ b/sw/wulpus-frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "wulpus-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@material-symbols/font-200": "^0.35.2", + "@material-symbols/font-300": "^0.35.2", + "@material-symbols/font-400": "^0.34.1", + "@tailwindcss/vite": "^4.1.12", + "@tanstack/react-query": "^5.90.2", + "plotly.js": "^3.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", + "react-plotly.js": "^2.6.0", + "react-range-slider-input": "^3.2.1", + "react-router": "^7.8.2", + "react-use-websocket": "^4.13.0", + "tailwindcss": "^4.1.12" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/plotly.js": "^3.0.3", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@types/react-plotly.js": "^2.6.3", + "@vitejs/plugin-react-swc": "^4.0.0", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2" + } +} diff --git a/sw/wulpus-frontend/public/vite.svg b/sw/wulpus-frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/sw/wulpus-frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sw/wulpus-frontend/src/AnalysisConfigPanel.tsx b/sw/wulpus-frontend/src/AnalysisConfigPanel.tsx new file mode 100644 index 0000000..a37910e --- /dev/null +++ b/sw/wulpus-frontend/src/AnalysisConfigPanel.tsx @@ -0,0 +1,104 @@ +import type { UseMutationResult } from '@tanstack/react-query'; +import { useState } from 'react'; +import { NumberField, StringField } from "./Fields"; +import { type AnalysisConfig, type ConnectResponse } from "./api"; +import { toast } from 'react-hot-toast'; + +export function AnalysisConfigPanel(props: { analyzeConfig?: AnalysisConfig, setAnalyzeConfig: UseMutationResult }) { + const { analyzeConfig, setAnalyzeConfig } = props; + const { spacers } = analyzeConfig ?? { spacers: [] }; + const [showAdvanced, setShowAdvanced] = useState(false); + + if (!analyzeConfig) { + return ( +
+

Analysis Config

+
Loading...
+
+ ); + } + else { + const updateSpacer = (idx: number, field: 'thickness' | 'speedOfSound' | 'note', value: number | string) => { + setAnalyzeConfig.mutate({ + ...analyzeConfig, + spacers: analyzeConfig.spacers.map((s, i) => i === idx ? { ...s, [field]: value } : s) + }) + } + const addSpacer = () => { + setAnalyzeConfig.mutate({ + ...analyzeConfig, + spacers: [...analyzeConfig.spacers, { thickness: 1.5, speedOfSound: 2500, note: '' }] + }, { + onError: (error) => { + toast.error(`Failed to add spacer - Is there a server?\n${error.message}`); + } + }); + } + const removeSpacer = (idx: number) => { + setAnalyzeConfig.mutate({ + ...analyzeConfig, + spacers: analyzeConfig.spacers.filter((_, i) => i !== idx) + }) + }; + + return ( +
+
+
+

Peak Detection

+ +
+
Configure sections to be ignored
+
+
+ {spacers.map((sp, idx) => ( +
+
+
Ignore Layer #{idx + 1}
+ +
+
+ updateSpacer(idx, 'thickness', v)} /> + updateSpacer(idx, 'speedOfSound', v)} /> +
+
+ updateSpacer(idx, 'note', v)} placeholder="optional" /> +
+
+ ))} +
+ + {spacers.length === 0 && ( +
No spacers defined. Add one.
+ )} +
+
+ +
+ ); + } +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/App.tsx b/sw/wulpus-frontend/src/App.tsx new file mode 100644 index 0000000..a4cb930 --- /dev/null +++ b/sw/wulpus-frontend/src/App.tsx @@ -0,0 +1,218 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import useWebSocket from 'react-use-websocket'; +import { AnalysisConfigPanel } from './AnalysisConfigPanel'; +import { fetchAnalyzeConfig, postAnalyzeConfig } from './api'; +import { ConfigFilesPanel } from './ConfigFilesPanel'; +import { ConnectionPanel } from './ConnectionPanel'; +import { Graph } from './Graph'; +import { getInitialConfig } from './helper'; +import { SeriesPanel } from './SeriesPanel'; +import { TxRxConfigPanel } from './TxRxConfig'; +import { USConfigPanel } from './UsConfig'; +import type { DataFrame, Status, TxRxConfig, UsConfig, WulpusConfig } from './websocket-types'; + +export const LOCAL_KEY = 'wulpus-config-v1'; +export const CHANNEL_SIZE = 8; + +function App() { + const queryClient = useQueryClient(); + + const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws`; + const { lastJsonMessage } = useWebSocket(wsUrl, { + shouldReconnect: () => true, + }); + + const [statuses, setStatuses] = useState([]); + const [dataFrames, setDataFrames] = useState([]); + const [selectedWulpusId, setSelectedWulpusId] = useState(0) + const selectedDataFrame = dataFrames.find(f => f.wulpus_id === (selectedWulpusId ?? 0)) + + // Derive primary status + const primaryStatus = statuses.length > 0 ? statuses[0] : null; + + + + const [bmodeBuffer, setBmodeBuffer] = useState(Array.from({ length: CHANNEL_SIZE }, () => [])); + const [peaksPerChannel, setPeaksPerChannel] = useState(Array.from({ length: CHANNEL_SIZE }, () => [])); + + // WulpusConfig state + const [txRxConfigs, setTxRxConfigs] = useState(getInitialConfig().tx_rx_config); + const [usConfig, setUsConfig] = useState(getInitialConfig().us_config); + + const effectiveConfig: WulpusConfig = useMemo(() => ({ + tx_rx_config: txRxConfigs, + us_config: { + ...usConfig, num_txrx_configs: txRxConfigs.length, + // ensure tx/rx bitmasks lists align in length + tx_configs: txRxConfigs.map((c) => c.config_id), + rx_configs: txRxConfigs.map((c) => c.config_id), + }, + }), [txRxConfigs, usConfig]); + + const saveConfigToLocalStorage = useCallback((config: WulpusConfig) => { + try { + localStorage.setItem(LOCAL_KEY, JSON.stringify(config)); + } catch (e) { + toast.error('Failed to store config in browser (localStorage)'); + } + }, []); + + useEffect(() => { + if (effectiveConfig) { + saveConfigToLocalStorage(effectiveConfig) + } + }, [effectiveConfig, saveConfigToLocalStorage]) + + + useEffect(() => { + if (lastJsonMessage) { + // Check if it's a status array (array of objects with 'status' field) + if (Array.isArray(lastJsonMessage) && (lastJsonMessage.length === 0 || (lastJsonMessage.length > 0 && 'status' in lastJsonMessage[0]))) { + setStatuses(lastJsonMessage); + if (!lastJsonMessage.some(s => s.wulpus_id === selectedWulpusId)) { + setSelectedWulpusId(lastJsonMessage[0].wulpus_id ?? 0); + } + } + // Check if it's a single DataFrame (has 'measurement' field) + else if ('measurement' in lastJsonMessage) { + const dataFrame = lastJsonMessage as DataFrame; + const deviceId = dataFrame.wulpus_id ?? 0; + setDataFrames(prev => { + const newFrames = [...prev]; + const existingIndex = newFrames.findIndex(f => (f.wulpus_id ?? 0) === deviceId); + if (existingIndex >= 0) { + newFrames[existingIndex] = dataFrame; + } else { + newFrames.push(dataFrame); + } + return newFrames; + }); + + // If this frame is the currently selected on, update b-mode buffer, and peaks + if (deviceId === selectedWulpusId) { + // Update B-mode buffer for the first/primary device + const rx_channel = dataFrame.measurement.rx; + const new_data = dataFrame.measurement.data.slice(); + setBmodeBuffer(prev => { + const next = [...prev]; + for (const channel of rx_channel) { + if (channel >= CHANNEL_SIZE) break; + next[channel] = new_data; + } + return next; + }); + + if (Array.isArray(dataFrame.peaks)) { + setPeaksPerChannel(prev => { + const next = [...prev]; + for (const channel of rx_channel) { + if (channel >= CHANNEL_SIZE) break; + next[channel] = dataFrame.peaks.slice(); + } + return next; + }); + } + } + } + } + }, [lastJsonMessage, selectedWulpusId]); + + const { data: analyzeConfig } = useQuery({ + queryKey: ['fetchAnalyzeConfig'], + queryFn: fetchAnalyzeConfig, + }) + const updateAnalyzeConfig = useMutation({ + mutationFn: postAnalyzeConfig, + // Optimistically update to the new value + onMutate: async (newData, context) => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await context.client.cancelQueries({ queryKey: ['fetchAnalyzeConfig'] }) + const previousVal = context.client.getQueryData(['fetchAnalyzeConfig']) + context.client.setQueryData(['fetchAnalyzeConfig'], () => newData) + return { previousVal } + }, + onError: (_err, _newTodo, onMutateResult, context) => { + context.client.setQueryData(['fetchAnalyzeConfig'], onMutateResult?.previousVal) + }, + onSettled: (_data, _error, _variables, _onMutateResult, _context) => + queryClient.invalidateQueries({ queryKey: ['fetchAnalyzeConfig'] }) + }) + + return ( +
+
+
+

Wulpus Dashboard

+ Recorded logs +
+
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+

+ Live Signal +

+ {statuses.length > 1 && ( + + )} +
+ +
+ +
+ +
+ +
+ { + if (conf?.tx_rx_config) setTxRxConfigs(conf.tx_rx_config); + if (conf?.us_config) setUsConfig(conf.us_config); + }} + /> +
+
+
+
+ ) +} + +export default App \ No newline at end of file diff --git a/sw/wulpus-frontend/src/ConfigFilesPanel.tsx b/sw/wulpus-frontend/src/ConfigFilesPanel.tsx new file mode 100644 index 0000000..32340fc --- /dev/null +++ b/sw/wulpus-frontend/src/ConfigFilesPanel.tsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { BASE_URL } from './api'; +import { getDefaultConfig } from './helper'; +import type { WulpusConfig } from './websocket-types'; + +type Props = { + effectiveConfig: WulpusConfig; + applyConfig?: (conf: WulpusConfig) => void; +}; + +async function listConfigs(): Promise { + const res = await fetch(`${BASE_URL}/configs`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +async function downloadConfig(filename: string): Promise { + const res = await fetch(`${BASE_URL}/configs/${encodeURIComponent(filename)}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +async function deleteConfig(filename: string): Promise { + const res = await fetch(`${BASE_URL}/configs/${encodeURIComponent(filename)}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(await res.text()); +} + +export function ConfigFilesPanel({ effectiveConfig, applyConfig }: Props) { + const [saving, setSaving] = useState(false); + const [saveName, setSaveName] = useState(''); + const [error, setError] = useState(null); + const [configList, setConfigList] = useState(null); + const [uploading, setUploading] = useState(false); + + const canSave = useMemo(() => { + return !saving && effectiveConfig && typeof effectiveConfig === 'object'; + }, [saving, effectiveConfig]); + + const refreshList = async () => { + try { + setConfigList(await listConfigs()); + } catch (e) { + setError(String(e)); + } + }; + + useEffect(() => { refreshList(); }, []); + + const onSave = async () => { + setError(null); + setSaving(true); + try { + const params = new URLSearchParams(); + if (saveName.trim()) params.set('name', saveName.trim()); + const res = await fetch(`${BASE_URL}/configs?${params.toString()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(effectiveConfig), + }); + if (!res.ok) throw new Error(await res.text()); + setSaveName(''); + await refreshList(); + toast.success('Configuration saved'); + } catch (e) { + setError(String(e)); + toast.error('Failed to save configuration'); + } finally { + setSaving(false); + } + }; + + const handleApplyConfig = (config: WulpusConfig) => { + applyConfig?.(config); + toast.success('Configuration applied'); + }; + + const applyFile = async (filename: string) => { + setError(null); + try { + const conf = await downloadConfig(filename); + handleApplyConfig(conf); + } catch (e) { + setError(String(e)); + } + }; + + const onDelete = async (filename: string) => { + setError(null); + const confirmed = window.confirm(`Delete config "${filename}"?`); + if (!confirmed) return; + try { + await deleteConfig(filename); + await refreshList(); + toast.success('Configuration deleted'); + } catch (e) { + setError(String(e)); + toast.error('Failed to delete configuration'); + } + }; + + const applyLocalFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setUploading(true); + setError(null); + + try { + const reader = new FileReader(); + reader.onload = async (e) => { + const content = e.target?.result; + if (typeof content === 'string') { + const config = JSON.parse(content); + handleApplyConfig(config); + } + }; + reader.readAsText(file); + } catch (e) { + setError(String(e)); + } finally { + setUploading(false); + } + }; + + return ( +
+

Config files

+ {error &&
{error}
} +
+
+
+ +

(.json will be added automatically)

+
+
+ setSaveName(e.target.value)} + placeholder="my-config" + className="mt-1 grow rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600" + /> + +
+
+
+ +
+
Existing configs
+ {configList === null ? ( +
Loading…
+ ) : configList.length === 0 ? ( +
No config files found.
+ ) : ( +
    + {configList.map((f) => ( +
  • + {f} +
    + + Download + +
    +
  • + ))} +
+ )} +
+ +
+ + +
+ + +
+ ); +} diff --git a/sw/wulpus-frontend/src/ConnectionPanel.tsx b/sw/wulpus-frontend/src/ConnectionPanel.tsx new file mode 100644 index 0000000..1f6ea77 --- /dev/null +++ b/sw/wulpus-frontend/src/ConnectionPanel.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useState } from "react"; +import { toast } from 'react-hot-toast'; +import { deactivateMock, getBTHConnections, postActivateMock, postConnect, postDisconnect, postStart, postStop, type ConnectionOption } from "./api"; +import { StatusView } from "./StatusView"; +import type { Status, WulpusConfig } from "./websocket-types"; + +export function ConnectionPanel(props: { effectiveConfig: WulpusConfig, status: Status | null, allStatuses?: Status[] }) { + const { effectiveConfig, status, allStatuses } = props; + const [connections, setConnections] = useState([]); + const [selectedConnection, setSelectedConnection] = useState(""); + const [isRefreshing, setIsRefreshing] = useState(false); + + const isMock = allStatuses?.some(status => status.mock) ?? false; + const someJobRunning = allStatuses?.some(s => s.status === 3) ?? false; + + const refreshConnections = useCallback(async () => { + setIsRefreshing(true); + toast('scanning...', { icon: bluetooth }); + const list = await getBTHConnections(); + setIsRefreshing(false); + setConnections(list); + const justPosts: string[] = list.map(item => item.device); + if (selectedConnection && !justPosts.includes(selectedConnection)) { + setSelectedConnection(""); + } + }, [selectedConnection]); + + + useEffect(() => { + refreshConnections() + }, [refreshConnections]); + + async function handleConnect(connection: string) { + console.log("Connecting to port:", connection); + if (!connection) return; + await postConnect(connection); + } + + async function handleDisconnect(connection: string) { + console.log("Disconnecting from port:", connection); + if (!connection) return; + await postDisconnect(connection); + } + + async function handleStart() { + toast('Starting...'); + try { + await postStart(effectiveConfig); + toast.success('Started'); + } catch (e) { + if (e instanceof Error) { + toast.error(`Failed to start: ${e.message}`); + } + } + } + + + return ( +
+
+

Connections {isMock ? ' (Simulation)' : ''}

+
+ {isMock && + <> + + + } + {!isMock && ( + + )} + +
+
+ {allStatuses?.sort((a, b) => b.status - a.status).map((s, idx) => ( + + ))} + {!someJobRunning && allStatuses?.every(s => s.status !== 0) && ( + + )} +
+ {allStatuses?.[0]?.status === undefined && ( +
Server not running!
+ )} + + {allStatuses?.[0]?.status !== undefined && !someJobRunning && ( + + )} + {someJobRunning && ( + + )} +
+
+
+ Progress: {Math.round((status?.progress ?? 0) * 100)}% +
+
+ ) +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/Fields.tsx b/sw/wulpus-frontend/src/Fields.tsx new file mode 100644 index 0000000..bfd6a8e --- /dev/null +++ b/sw/wulpus-frontend/src/Fields.tsx @@ -0,0 +1,36 @@ +// UI helpers +export function NumberField({ label, value, onChange, step = 1, min, helper }: { label: string; value: number; onChange: (v: number) => void; step?: number; min?: number; helper?: string; }) { + return ( + + ); +} +export function SelectField({ label, value, onChange, options }: { label: string; value: string | number; onChange: (v: string) => void; options: { value: string; label: string; }[]; }) { + return ( + + ); +} + +export function StringField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; }) { + return ( + + ); +} diff --git a/sw/wulpus-frontend/src/Graph.tsx b/sw/wulpus-frontend/src/Graph.tsx new file mode 100644 index 0000000..500a1ea --- /dev/null +++ b/sw/wulpus-frontend/src/Graph.tsx @@ -0,0 +1,180 @@ +import type Plotly from 'plotly.js'; +import { useCallback, useEffect, useRef, useState } from "react"; +import Plot from 'react-plotly.js'; +import { bandpassFIR, hilbertEnvelope, toggleFullscreen } from './helper'; +import type { DataFrame, UsConfig } from './websocket-types'; +import RangeSlider from 'react-range-slider-input'; + +export function Graph(props: { dataFrame: DataFrame | undefined, bmodeBuffer: number[][], peaksPerChannel: number[][], usConfig: UsConfig }) { + const { dataFrame, bmodeBuffer, peaksPerChannel, usConfig } = props; + const data = dataFrame?.measurement.data ?? []; + const wavelet_transform = dataFrame?.wavelet ?? []; + const peaks = dataFrame?.peaks ?? []; + const sampling_freq = usConfig.sampling_freq; + const plotContainerRef = useRef(null); + const [showBMode, setShowBMode] = useState(false); + const UPSAMPLING_FACTOR = 10 + + // fullscreen graph support + const [isFullscreen, setIsFullscreen] = useState(false); + + // track fullscreen changes + useEffect(() => { + const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); + document.addEventListener('fullscreenchange', onFsChange); + return () => document.removeEventListener('fullscreenchange', onFsChange); + }, []); + + // compute filter/envelope just-in-time before rendering + const minLowCutHz = useCallback((sampling_freq: number) => sampling_freq / 2 * 0.1, []); + const maxHighCutHz = useCallback((sampling_freq: number) => sampling_freq / 2 * 0.9, []); + const [lowCutHz, setLowCutHz] = useState(minLowCutHz(sampling_freq)); + const [highCutHz, setHighCutHz] = useState(maxHighCutHz(sampling_freq)); + const filteredFrame = data ? bandpassFIR(data, sampling_freq, lowCutHz, highCutHz, 31) : []; + const envelopeFrame = filteredFrame.length ? hilbertEnvelope(filteredFrame, 101) : []; + + useEffect(() => { + setLowCutHz(minLowCutHz(sampling_freq)); + setHighCutHz(maxHighCutHz(sampling_freq)); + }, [sampling_freq, setHighCutHz, minLowCutHz, maxHighCutHz]); + + // Rx channels for current frame (if provided) + + // Vertical line shapes for the time-domain (non B-mode) plot spanning full height + const signalPeakShapes: Partial[] = peaks.map(p => ({ + type: 'line', x0: p, x1: p, xref: 'x', yref: 'paper', y0: 0, y1: 1, + line: { color: 'rgba(255,140,0,0.35)', width: 2, dash: 'dot' }, layer: 'below' + })); + + const spacerShape: Partial = { + type: 'rect', x0: 0, x1: dataFrame?.spacer_region[1] ?? 0, xref: 'x', yref: 'paper', y0: 0, y1: 1, + line: { color: 'rgba(100,120,135,0.35)' }, fillcolor: 'rgba(100,120,135,0.35)', layer: 'below' + } + + // For B-Mode heatmap: draw peak lines only over the rows (channels) that belong to this measurement's rx set. + // Heatmap implicit y coordinates: row indices 0..N-1. We'll span each channel row from (ch-0.5) to (ch+0.5) + const bmodePeakShapes: Partial[] = []; + if (peaksPerChannel && peaksPerChannel.length) { + for (let ch = 0; ch < peaksPerChannel.length; ch++) { + const chPeaks = peaksPerChannel[ch]; + if (!chPeaks || !chPeaks.length) continue; + if (ch < 0 || ch >= bmodeBuffer.length) continue; + for (const p of chPeaks) { + bmodePeakShapes.push({ + type: 'line', + x0: p, x1: p, + xref: 'x', yref: 'y', + y0: ch - 0.5, y1: ch + 0.5, + line: { color: 'rgba(255,140,0,0.55)', width: 2, dash: 'dot' }, + layer: 'above' + }); + } + } + } + + return ( +
+
+ {showBMode ? ( + + ) : ( + i) : [], + y: data ?? [], + type: 'scatter', mode: 'lines', name: 'Raw', line: { color: 'blue' }, + }, + { + x: data ? data.map((_, i) => i) : [], + y: filteredFrame.length ? filteredFrame : [], + type: 'scatter', mode: 'lines', name: 'Filter', line: { color: 'green' }, + visible: 'legendonly', + }, + { + x: data ? data.map((_, i) => i) : [], + y: envelopeFrame.length ? envelopeFrame : [], + type: 'scatter', mode: 'lines', name: 'Envelope', line: { color: 'fuchsia' }, + visible: 'legendonly', + }, + { + x: wavelet_transform ? wavelet_transform.map((_, i) => i / UPSAMPLING_FACTOR) : [], + y: wavelet_transform ?? [], + type: 'scatter', mode: 'lines', name: 'Wavelet Envelope', line: { color: 'red' }, + visible: 'legendonly', + } + ]) as Plotly.Data[]} + useResizeHandler + style={{ width: "100%", height: "100%" }} + layout={{ + autosize: true, + uirevision: "fixed", + showlegend: true, + legend: { orientation: 'h' }, + margin: { t: 10, r: 10, b: 30, l: 40 }, + yaxis: { range: [-2000, 2000] }, + shapes: [...signalPeakShapes, spacerShape], + }} + /> + )} +
+
+ + + + {!showBMode && ( + <> +
+ Filter: +
+ {Math.round(lowCutHz / 1e4) / 100} MHz +
+ { + const [low, high] = i; + setLowCutHz(low); + setHighCutHz(high); + }} + /> +
+ {Math.round(highCutHz / 1e4) / 100} MHz +
+
+
+ + {dataFrame?.measurement.rx && dataFrame.measurement.rx.length > 0 ? `Rx: ${dataFrame.measurement.rx.join(', ')}` : 'No Signal'} + +
+ + )} +
+
) +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/LogsPage.tsx b/sw/wulpus-frontend/src/LogsPage.tsx new file mode 100644 index 0000000..f4b6e95 --- /dev/null +++ b/sw/wulpus-frontend/src/LogsPage.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from "react-router"; +import { BASE_URL, replayFile } from './api'; + +export function LogsPage() { + const [files, setFiles] = useState(null); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + let cancelled = false; + fetch(`${BASE_URL}/logs`) + .then(async (res) => { + if (!res.ok) throw new Error(await res.text()); + return res.json(); + }) + .then((data: string[]) => { if (!cancelled) setFiles(data); }) + .catch((e) => { if (!cancelled) setError(String(e)); }); + return () => { cancelled = true; }; + }, []); + + const handleReplay = (filename: string) => { + // Implement replay functionality here + replayFile(filename) + .then(() => { + navigate("/"); + }) + .catch((e) => { + setError(String(e)); + }); + }; + + return ( +
+
+
+

Recorded logs

+ Back to Dashboard +
+ +
+
+ {error && ( +
{error}
+ )} + {files === null ? ( +
Loading…
+ ) : files.length === 0 ? ( +
No logs found.
+ ) : ( +
    + {files.map((f) => ( +
  • + {f} + + + Download + +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/sw/wulpus-frontend/src/MultiNumField.tsx b/sw/wulpus-frontend/src/MultiNumField.tsx new file mode 100644 index 0000000..67add94 --- /dev/null +++ b/sw/wulpus-frontend/src/MultiNumField.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from 'react'; +import { CHANNEL_SIZE } from './App'; + +export function MultiNumField({ label, values, onChange, showChannelBoxes = false, color = 'bg-green-500' }: { + label: string; + values: number[]; + onChange: (vals: number[]) => void; + showChannelBoxes?: boolean; + color?: string; +}) { + const [text, setText] = useState(values.join(',')); + const inputRef = useRef(null); + + // Only overwrite the text when the input is not focused. This prevents + // removing a trailing comma while the user is typing (which made "," disappear). + useEffect(() => { + try { + const active = typeof document !== 'undefined' ? document.activeElement : null; + if (inputRef.current && active === inputRef.current) { + // user is editing — don't clobber their input + return; + } + } catch (e) { + // ignore (e.g., SSR) + } + setText(values.join(',')); + }, [values]); + + function toggleChannel(ch: number) { + const exists = values.includes(ch); + const next = exists ? values.filter(v => v !== ch) : [...values, ch].sort((a, b) => a - b); + onChange(next); + } + return ( + + ); +} diff --git a/sw/wulpus-frontend/src/SeriesPanel.tsx b/sw/wulpus-frontend/src/SeriesPanel.tsx new file mode 100644 index 0000000..fdeb9e4 --- /dev/null +++ b/sw/wulpus-frontend/src/SeriesPanel.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { startSeries, stopSeries } from './api'; +import type { SeriesStatus, WulpusConfig } from './websocket-types'; +import { toast } from 'react-hot-toast'; +import { NumberField } from './Fields'; + +export function SeriesPanel(props: { effectiveConfig: WulpusConfig, disabled?: boolean, seriesStatus?: SeriesStatus }) { + const { effectiveConfig, disabled, seriesStatus } = props; + const [intervalMinutes, setIntervalMinutes] = useState(5); + const intervalSeconds = intervalMinutes * 60; + const [nIntervals, setNIntervals] = useState(10); + const status: SeriesStatus = seriesStatus || { active: false }; + + async function handleStart() { + try { + const est = (effectiveConfig.us_config.num_acqs * effectiveConfig.us_config.meas_period) / 1e6; + if (est >= intervalSeconds) { + toast.error(`Estimated duration ${(est).toFixed(2)}s >= interval ${intervalSeconds}s`); + return; + } + await startSeries(intervalSeconds, effectiveConfig, nIntervals); + toast.success('Series started'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + toast.error(msg); + } + } + + async function handleStop() { + try { + await stopSeries(); + toast('Series stopped'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + toast.error(msg); + } + } + + return ( +
+
+

Measurement Series

+ {seriesStatus?.active && + <> + + + } +
+ + + {!status.active && + <> +
+ setIntervalMinutes(v)} /> + setNIntervals(v)} /> +
+
+
+ +
+
Total Duration: {intervalMinutes * nIntervals}min
+
+ + } + {status.active && + <> +
Progress: {status.progress_count} of {status.number} + {" "}(every {status.interval_seconds}s)
+ + + } +
+ ); +} diff --git a/sw/wulpus-frontend/src/StatusView.tsx b/sw/wulpus-frontend/src/StatusView.tsx new file mode 100644 index 0000000..dacf6fb --- /dev/null +++ b/sw/wulpus-frontend/src/StatusView.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { StatusLabel, type ConnectionOption } from "./api"; +import type { Status } from "./websocket-types"; + +type Handler = (connection: string) => void + +export function StatusView(props: { + status?: Status, + handleConnect?: Handler, + handleDisconnect?: Handler, + connections: ConnectionOption[] + disabled?: boolean +}) { + const { status, handleConnect, handleDisconnect, connections } = props; + const disabled = props.disabled ?? false; + const [selectedConnection, setSelectedConnection] = useState(""); + + const commonButtonStyle: React.HTMLAttributes['className'] = "text-white rounded items-center inline-flex justify-center px-3 py-2 h-9 max-w-23 disabled:opacity-50 disabled:cursor-not-allowed"; + + const connectionValue = ((status?.status ?? 0) !== 0) ? (status?.endpoint ?? "") : selectedConnection; + return ( +
+
+ + {(status === undefined || status?.status === 0) && ( + + )} + {status?.status !== undefined && status.status !== 0 && ( + + )} +
+ {!!status && +
+ Status: {status ? StatusLabel(status.status) : 'No Server/Backend'} · BT {status?.bluetooth ?? '—'} +
+ } +
+ ) +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/TxRxConfig.tsx b/sw/wulpus-frontend/src/TxRxConfig.tsx new file mode 100644 index 0000000..dcd0c16 --- /dev/null +++ b/sw/wulpus-frontend/src/TxRxConfig.tsx @@ -0,0 +1,64 @@ +import { CHANNEL_SIZE } from "./App"; +import { MultiNumField } from "./MultiNumField"; +import type { TxRxConfig } from './websocket-types'; + +export function TxRxConfigPanel(props: { txRxConfigs: TxRxConfig[], setTxRxConfigs: React.Dispatch> }) { + const { txRxConfigs, setTxRxConfigs } = props; + + function updateTxRx(idx: number, field: K, value: TxRxConfig[K]) { + setTxRxConfigs((prev) => prev.map((c, i) => i === idx ? { ...c, [field]: value } : c)); + } + + function addTxRx() { + setTxRxConfigs((prev) => [...prev, { config_id: prev.length, tx_channels: [], rx_channels: [], optimized_switching: true }]); + } + + function removeTxRx(idx: number) { + setTxRxConfigs((prev) => prev.filter((_, i) => i !== idx).map((c, i) => ({ ...c, config_id: i }))); + } + + + return ( +
+

TX/RX Configurations

+
+ {txRxConfigs.map((cfg, idx) => ( +
+
+
Config #{cfg.config_id}
+ +
+
+ updateTxRx(idx, 'tx_channels', vals)} + showChannelBoxes={true} + color="bg-green-500" /> + updateTxRx(idx, 'rx_channels', vals)} + showChannelBoxes={true} + color="bg-blue-500" /> +
+ updateTxRx(idx, 'optimized_switching', e.target.checked)} + /> + +
+
+
+ ))} + {txRxConfigs.length < CHANNEL_SIZE && ( + + )} +
+
+ ) +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/UsConfig.tsx b/sw/wulpus-frontend/src/UsConfig.tsx new file mode 100644 index 0000000..7b4e40b --- /dev/null +++ b/sw/wulpus-frontend/src/UsConfig.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { NumberField, SelectField } from "./Fields"; +import type { UsConfig } from './websocket-types'; + +export function USConfigPanel(props: { usConfig: UsConfig, setUsConfig: React.Dispatch> }) { + const { usConfig, setUsConfig } = props; + const [showAdvanced, setShowAdvanced] = useState(false); + + const advancedFields = ( + <> + setUsConfig(s => ({ ...s, dcdc_turnon: v }))} /> + setUsConfig(s => ({ ...s, trans_freq: v }))} /> + setUsConfig(s => ({ ...s, pulse_freq: v }))} /> + setUsConfig(s => ({ ...s, sampling_freq: Number(v) as UsConfig['sampling_freq'] }))} + options={[8000000, 4000000, 2000000, 1000000, 500000].map(v => ({ value: String(v), label: String(v / 1000000) + "MHz" }))} + /> + setUsConfig(s => ({ ...s, rx_gain: parseFloat(v) }))} + options={[-6.5, -5.5, -4.6, -4.1, -3.3, -2.3, -1.4, -0.8, + 0.1, 1.0, 1.9, 2.6, 3.5, 4.4, 5.2, 6.0, 6.8, 7.7, + 8.7, 9.0, 9.8, 10.7, 11.7, 12.2, 13, 13.9, 14.9, + 15.5, 16.3, 17.2, 18.2, 18.8, 19.6, 20.5, 21.5, + 22, 22.8, 23.6, 24.6, 25.0, 25.8, 26.7, 27.7, + 28.1, 28.9, 29.8, 30.8].map(v => ({ value: String(v), label: String(v) }))} + /> + setUsConfig(s => ({ ...s, start_hvmuxrx: v }))} /> + setUsConfig(s => ({ ...s, start_ppg: v }))} /> + setUsConfig(s => ({ ...s, turnon_adc: v }))} /> + setUsConfig(s => ({ ...s, start_pgainbias: v }))} /> + setUsConfig(s => ({ ...s, start_adcsampl: v }))} /> + setUsConfig(s => ({ ...s, restart_capt: v }))} /> + setUsConfig(s => ({ ...s, capt_timeout: v }))} /> + + ); + + return ( +
+
+

Measurement Config

+ +
+
{/* Keep two columns either way */} + setUsConfig(s => ({ ...s, num_acqs: v }))} /> + setUsConfig(s => ({ ...s, num_samples: v }))} /> + setUsConfig(s => ({ ...s, meas_period: v }))} /> + setUsConfig(s => ({ ...s, num_pulses: v }))} /> + {showAdvanced && advancedFields} +
+
+ ); +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/api.ts b/sw/wulpus-frontend/src/api.ts new file mode 100644 index 0000000..c9943a2 --- /dev/null +++ b/sw/wulpus-frontend/src/api.ts @@ -0,0 +1,201 @@ +// Simple API client for the FastAPI backend + +import type { WulpusConfig } from "./websocket-types"; + +export type ConnectResponse = { ok: string } | { [key: string]: string }; + +export type ConnectionType = 'serial' | 'ble'; +export type ConnectionOption = { + device: string; // e.g. COM5 or BLE MAC/address + description: string; // human-friendly label + type: ConnectionType; // 'serial' | 'ble' +}; + + +export type AnalysisConfig = { + spacers: { "thickness": number, "note"?: string, "speedOfSound": number }[] + peakConsistency: number, + peakThreshold: number, + peakHistory: number, + nMaxPeaks: number, + upsamplingFactor: number, +}; + +// Use Vite proxy in dev to avoid CORS; see vite.config.ts +export const BASE_URL = "/api"; + +export async function getBTHConnections(): Promise { + const res = await fetch(`${BASE_URL}/connections`); + if (!res.ok) throw new Error(`GET /connections failed: ${res.status}`); + const data = await res.json(); + + if (Array.isArray(data)) { + // Handle new multi-device format: [{ wulpus_id?, options: [{ device, description, type }] }, ...] + // or old format: [{ device, description, type }, ...] + const allItems: ConnectionOption[] = []; + + for (const item of data) { + if (item && typeof item === 'object') { + // New format with options array + if ('options' in item && Array.isArray(item.options)) { + const deviceId = item.wulpus_id ?? 0; + const devicePrefix = deviceId > 0 ? `[${deviceId}] ` : ''; + + for (const option of item.options) { + const obj = option as Partial & Record; + const t = obj.type === 'ble' || obj.type === 'serial' ? obj.type : 'serial'; + allItems.push({ + device: String(obj.device ?? ''), + description: devicePrefix + String(obj.description ?? obj.device ?? ''), + type: t, + } as ConnectionOption); + } + } else { + // Old format - direct device object + const obj = item as Partial & Record; + const t = obj.type === 'ble' || obj.type === 'serial' ? obj.type : 'serial'; + allItems.push({ + device: String(obj.device ?? ''), + description: String(obj.description ?? obj.device ?? ''), + type: t, + } as ConnectionOption); + } + } + } + + const uniqueItems = Array.from(new Map(allItems.map(i => [i.device, i])).values()); + return uniqueItems; + } + return []; +} + +export async function postConnect(conDev: string): Promise { + const res = await fetch(`${BASE_URL}/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ con_dev: conDev }), + }); + if (!res.ok) throw new Error(`POST /connect failed: ${res.status}`); +} + +export async function postDisconnect(conDev?: string): Promise { + let res: Response + if (!conDev) { + res = await fetch(`${BASE_URL}/disconnect/all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + } else { + res = await fetch(`${BASE_URL}/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ con_dev: conDev }), + }); + } + if (!res.ok) throw new Error(`POST /disconnect failed: ${res.status}`); +} + +export async function postStart(config: WulpusConfig): Promise { + const res = await fetch(`${BASE_URL}/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + if (!res.ok) { + let message = `${res.status}`; + try { + const errorData = await res.json(); + message += ` (${errorData.detail[0]?.msg ?? JSON.stringify(errorData)})`; + } + catch { /* empty */ } + throw new Error(message) + }; + return res.json(); +} + +export async function postStop(): Promise { + const res = await fetch(`${BASE_URL}/stop`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(`POST /stop failed: ${res.status}`); + return res.json(); +} + +export async function postActivateMock(): Promise { + const res = await fetch(`${BASE_URL}/activate-mock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(`POST /activate-mock failed: ${res.status}`); +} + +export async function deactivateMock(): Promise { + const res = await fetch(`${BASE_URL}/deactivate-mock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(`POST /deactivate-mock failed: ${res.status}`); + return res.json(); +} + +export async function replayFile(filename: string): Promise { + const res = await fetch(`${BASE_URL}/replay/${encodeURIComponent(filename)}`, { + method: 'POST', + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(await res.text()); +} + +export async function getLogs(): Promise { + const res = await fetch(`${BASE_URL}/logs`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export function StatusLabel(s?: number) { + switch (s) { + case 0: return 'NOT_CONNECTED'; + case 1: return 'CONNECTING'; + case 2: return 'READY'; + case 3: return 'RUNNING'; + case 9: return 'ERROR'; + default: return String(s ?? '—'); + } +} + +export async function startSeries(intervalSeconds: number, config: WulpusConfig, number: number) { + const res = await fetch(`${BASE_URL}/series/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interval_seconds: intervalSeconds, + config, + number + }) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function stopSeries() { + const res = await fetch(`${BASE_URL}/series/stop`, { method: 'POST' }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function postAnalyzeConfig(config: AnalysisConfig): Promise { + const res = await fetch(`${BASE_URL}/analyzeConfig`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error(`POST /analyzeConfig failed: ${res.status}`); + return res.json(); +} + +export async function fetchAnalyzeConfig(): Promise { + const res = await fetch(`${BASE_URL}/analyzeConfig`); + if (!res.ok) throw new Error(`GET /analyzeConfig failed: ${res.status}`); + return res.json(); +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/assets/react.svg b/sw/wulpus-frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/sw/wulpus-frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sw/wulpus-frontend/src/helper.ts b/sw/wulpus-frontend/src/helper.ts new file mode 100644 index 0000000..23e5a70 --- /dev/null +++ b/sw/wulpus-frontend/src/helper.ts @@ -0,0 +1,158 @@ +import { LOCAL_KEY } from "./App"; +import type { WulpusConfig } from "./websocket-types"; + + +export const getDefaultConfig = (): WulpusConfig => { + return { + tx_rx_config: [{ config_id: 0, tx_channels: [0], rx_channels: [0], optimized_switching: true }], + us_config: { + num_acqs: 500, + dcdc_turnon: 100, + meas_period: 321965, + trans_freq: 2250000, + pulse_freq: 2250000, + num_pulses: 1, + sampling_freq: 8000000, + num_samples: 400, + rx_gain: 3.5, + num_txrx_configs: 1, + tx_configs: [0], + rx_configs: [1], + start_hvmuxrx: 500, + start_ppg: 500, + turnon_adc: 5, + start_pgainbias: 5, + start_adcsampl: 503, + restart_capt: 3000, + capt_timeout: 3000, + } + } +} + +export const getInitialConfig = () => { + let defaultConfig: WulpusConfig = getDefaultConfig(); + const raw = localStorage.getItem(LOCAL_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + if (parsed && typeof parsed === 'object' && parsed.tx_rx_config && parsed.us_config) { + defaultConfig = parsed as WulpusConfig; + } + } + return defaultConfig; +}; + + +// Helper DSP utilities +function sinc(x: number) { + if (x === 0) return 1; + const pix = Math.PI * x; + return Math.sin(pix) / pix; +} + +function hammingWindow(n: number) { + const ALPHA = 0.54; + const BETA = 0.46; + const out = new Array(n); + + for (let i = 0; i < n; i++) { + out[i] = ALPHA - BETA * Math.cos((2 * Math.PI * i) / (n - 1)); + } + return out; +} + +export function bandpassFIR(data: number[], fs: number, lowHz: number, highHz: number, nTaps = 101) { + // design windowed-sinc bandpass (linear-phase FIR) + if (nTaps % 2 === 0) nTaps += 1; // make odd + const mid = (nTaps - 1) / 2; + const low = lowHz / fs; // normalized (0..0.5) + const high = highHz / fs; + const win = hammingWindow(nTaps); + const h: number[] = new Array(nTaps); + for (let n = 0; n <= (nTaps - 1); n++) { + const k = n - mid; + // ideal bandpass = high * sinc(2*high*k) - low * sinc(2*low*k) + h[n] = 2 * high * sinc(2 * high * k) - 2 * low * sinc(2 * low * k); + h[n] *= win[n]; + } + // apply forward-backward filtering to approximate filtfilt (zero-phase) + const tmp = new Array(data.length).fill(0); + for (let i = 0; i < data.length; i++) { + let acc = 0; + for (let k = 0; k < nTaps; k++) { + const idx = i - (nTaps - 1 - k); + if (idx >= 0 && idx < data.length) acc += h[k] * data[idx]; + } + tmp[i] = acc; + } + + // reverse, filter again, then reverse to get zero-phase effect + const revIn = tmp.slice().reverse(); + const tmp2 = new Array(data.length).fill(0); + for (let i = 0; i < revIn.length; i++) { + let acc = 0; + for (let k = 0; k < nTaps; k++) { + const idx = i - (nTaps - 1 - k); + if (idx >= 0 && idx < revIn.length) acc += h[k] * revIn[idx]; + } + tmp2[i] = acc; + } + return tmp2.reverse(); +} + +export function hilbertEnvelope(data: number[], nTaps = 101) { + // approximate analytic signal via FIR Hilbert transformer + if (nTaps % 2 === 0) nTaps += 1; // ensure odd + const mid = (nTaps - 1) / 2; + const win = hammingWindow(nTaps); + const h: number[] = new Array(nTaps).fill(0); + for (let n = 0; n < nTaps; n++) { + const k = n - mid; + if (k === 0) { + h[n] = 0; + } else if (k % 2 === 0) { + h[n] = 0; + } else { + h[n] = 2 / (Math.PI * k); + } + h[n] *= win[n]; + } + // compute imaginary part (convolution) + const imag = new Array(data.length).fill(0); + for (let i = 0; i < data.length; i++) { + let acc = 0; + for (let k = 0; k < nTaps; k++) { + const idx = i - (nTaps - 1 - k); + if (idx >= 0 && idx < data.length) acc += h[k] * data[idx]; + } + imag[i] = acc; + } + // envelope sqrt(real^2 + imag^2) + const out = new Array(data.length); + for (let i = 0; i < data.length; i++) { + out[i] = Math.hypot(data[i], imag[i]); + } + return out; +} + + +export async function toggleFullscreen(plotContainerRef: React.RefObject) { + const el = plotContainerRef.current; + if (!el) return; + if (!document.fullscreenElement) { + const elWithVendors = el as HTMLElement & { + webkitRequestFullscreen?: () => Promise | void; + msRequestFullscreen?: () => Promise | void; + }; + if (elWithVendors.requestFullscreen) await elWithVendors.requestFullscreen(); + else if (elWithVendors.webkitRequestFullscreen) await elWithVendors.webkitRequestFullscreen(); + else if (elWithVendors.msRequestFullscreen) await elWithVendors.msRequestFullscreen(); + } else { + const docWithVendors = document as Document & { + webkitExitFullscreen?: () => Promise | void; + msExitFullscreen?: () => Promise | void; + }; + if (document.exitFullscreen) await document.exitFullscreen(); + else if (docWithVendors.webkitExitFullscreen) await docWithVendors.webkitExitFullscreen(); + else if (docWithVendors.msExitFullscreen) await docWithVendors.msExitFullscreen(); + } +} \ No newline at end of file diff --git a/sw/wulpus-frontend/src/index.css b/sw/wulpus-frontend/src/index.css new file mode 100644 index 0000000..c6607cc --- /dev/null +++ b/sw/wulpus-frontend/src/index.css @@ -0,0 +1,44 @@ +@import "tailwindcss"; +@import "@material-symbols/font-200"; +@import "@material-symbols/font-300"; +@import "@material-symbols/font-400"; +@import "react-range-slider-input/dist/style.css"; + +@layer base { + button:not([disabled]), + [role="button"]:not([disabled]) { + cursor: pointer; + } + @font-face { + font-family: "Material Symbols Rounded"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("./material-symbols-rounded.woff2") format("woff2"); + + .material-symbols-rounded { + font-family: "Material Symbols Rounded"; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-feature-settings: "liga"; + } + } +} +.range-slider .range-slider__thumb { + background: #008000; +} + +.range-slider .range-slider__range { + background: #005f00; +} diff --git a/sw/wulpus-frontend/src/main.tsx b/sw/wulpus-frontend/src/main.tsx new file mode 100644 index 0000000..38ea88a --- /dev/null +++ b/sw/wulpus-frontend/src/main.tsx @@ -0,0 +1,28 @@ +import { + QueryClient, + QueryClientProvider +} from '@tanstack/react-query'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Toaster } from 'react-hot-toast'; +import { createBrowserRouter } from "react-router"; +import { RouterProvider } from "react-router/dom"; +import App from './App.tsx'; +import './index.css'; +import { LogsPage } from './LogsPage'; + +const router = createBrowserRouter([ + { path: '/', element: }, + { path: '/data', element: }, +]) + +const queryClient = new QueryClient() + +createRoot(document.getElementById('root')!).render( + + + + + + , +) diff --git a/sw/wulpus-frontend/src/vite-env.d.ts b/sw/wulpus-frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/sw/wulpus-frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/sw/wulpus-frontend/src/websocket-types.ts b/sw/wulpus-frontend/src/websocket-types.ts new file mode 100644 index 0000000..6851054 --- /dev/null +++ b/sw/wulpus-frontend/src/websocket-types.ts @@ -0,0 +1,67 @@ +// Types mirrored from backend pydantic models + +export type UsConfig = { + num_acqs: number; + dcdc_turnon: number; + meas_period: number; + trans_freq: number; + pulse_freq: number; + num_pulses: number; + sampling_freq: 8000000 | 4000000 | 2000000 | 1000000 | 500000; + num_samples: number; + rx_gain: number; // constrained to predefined values on backend + num_txrx_configs: number; + tx_configs: number[]; + rx_configs: number[]; + start_hvmuxrx: number; + start_ppg: number; + turnon_adc: number; + start_pgainbias: number; + start_adcsampl: number; + restart_capt: number; + capt_timeout: number; +}; + +export type TxRxConfig = { + config_id: number; + tx_channels: number[]; + rx_channels: number[]; + optimized_switching: boolean; +}; + +export type WulpusConfig = { + tx_rx_config: TxRxConfig[]; + us_config: UsConfig; +}; + +export type SeriesStatus = { + active: boolean; + interval_seconds?: number; + number?: number; + progress_count?: number; +} + +export type Status = { + mock?: boolean; + status: number; // 0.., maps to backend Status enum + bluetooth: string; + endpoint: string; + us_config: UsConfig | null; + tx_rx_config: TxRxConfig[] | null; + progress: number; // 0..1 + series?: SeriesStatus; + wulpus_id?: number; +}; + +export type DataFrame = { + measurement: { + data: number[] + time: number[] + tx: number[] + rx: number[] + } + peaks: number[] + wavelet: number[] + spacer_region: [number, number] + wulpus_id?: number; +} \ No newline at end of file diff --git a/sw/wulpus-frontend/tsconfig.app.json b/sw/wulpus-frontend/tsconfig.app.json new file mode 100644 index 0000000..227a6c6 --- /dev/null +++ b/sw/wulpus-frontend/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/sw/wulpus-frontend/tsconfig.json b/sw/wulpus-frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/sw/wulpus-frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/sw/wulpus-frontend/tsconfig.node.json b/sw/wulpus-frontend/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/sw/wulpus-frontend/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/sw/wulpus-frontend/vite.config.ts b/sw/wulpus-frontend/vite.config.ts new file mode 100644 index 0000000..450a4e3 --- /dev/null +++ b/sw/wulpus-frontend/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + }, + "/logs": { + target: "http://localhost:8000", + changeOrigin: true, + }, + "/configs": { + target: "http://localhost:8000", + changeOrigin: true, + }, + "/ws": { + target: "ws://localhost:8000", + ws: true, + changeOrigin: true, + }, + }, + }, +}) diff --git a/sw/wulpus/.gitignore b/sw/wulpus/.gitignore new file mode 100644 index 0000000..edd9d60 --- /dev/null +++ b/sw/wulpus/.gitignore @@ -0,0 +1,2 @@ +build/ +dist/ diff --git a/sw/wulpus/config-analysis/.gitignore b/sw/wulpus/config-analysis/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/sw/wulpus/config-analysis/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/sw/wulpus/configs/.gitignore b/sw/wulpus/configs/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/sw/wulpus/configs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/sw/wulpus/data_processing.py b/sw/wulpus/data_processing.py new file mode 100644 index 0000000..423d0e9 --- /dev/null +++ b/sw/wulpus/data_processing.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import math +import os +import inspect +import pywt +import numpy as np +import pandas as pd + +from collections import defaultdict, deque +from typing import TYPE_CHECKING, Deque, Dict, List, Optional, Union +from pydantic import BaseModel, Field, field_validator +from scipy.signal import find_peaks, hilbert, resample +from wulpus.wulpus_model import HTTPMeasurementResponse +import wulpus as wulpus_pkg + +if TYPE_CHECKING: + from wulpus.wulpus import Measurement + from wulpus.wulpus_config_models import WulpusConfig + +ANALYSIS_CONFIG_DIR = os.path.join(os.path.dirname( + inspect.getfile(wulpus_pkg)), 'config-analysis') +ANALYSIS_CONFIG_FILENAME = 'analysis_config.json' + + +class AnalysisConfig(BaseModel): + spacers: List[Dict[str, Union[float, str]]] = [ + {"thickness": 0.6, "note": "PDMS", "speedOfSound": 1030}, + {"thickness": 8, "note": "PEEK spacer", "speedOfSound": 2500} + ] + peakConsistency: int = Field(default=5, ge=1) + peakThreshold: float = Field(default=5, ge=0.0) + peakHistory: int = Field(default=10, ge=1) + nMaxPeaks: int = Field(default=5, ge=1) + upsamplingFactor: int = Field(default=10, ge=1) + + +class MeasurementProcessor: + """Stateful processor that computes peaks and keeps recent history to filter for consistency. + + Consistency definition: a peak (sample index, possibly fractional) is considered consistent for a + channel if it appears (within tolerance) in at least ``self.consistency`` of the last + ``self.peakHistory`` measurements for that channel (including the current one). + """ + + def __init__(self, config: Optional[AnalysisConfig] = None): + # channel -> deque of list[float] (peaks for each measurement) + if (config): + self.config = config + else: + self.config = AnalysisConfig() + self._history: Dict[int, Deque[List[float]]] = defaultdict( + lambda: deque(maxlen=self.config.peakHistory)) + + def get_analyze_config(self) -> AnalysisConfig: + if not self.config: + self.config = AnalysisConfig() + return self.config + + def set_analyze_config(self, config: AnalysisConfig): + self.config = config + self.reset() + + def process_measurement(self, measurement: Measurement, config: WulpusConfig) -> HTTPMeasurementResponse: + series = pd.Series(measurement['data']) + + series_ups = self._upsample(series, self.config.upsamplingFactor) + t_ups = series_ups.index.to_numpy() + wavelet = self._wavelet_transform( + series_ups, config, select_level=5) + wavelet_env = self._envelope(wavelet) + + spacer_region = self._calc_spacer_region(config) + + raw_peaks = self._calc_peaks( + wavelet_env, config, spacer_reflection=spacer_region) + peaks_x = list(map(lambda p: t_ups[p], raw_peaks)) + filtered_peaks = list( + filter(lambda x: spacer_region[0] < x, peaks_x)) + + rx_channels = measurement['rx'] + for ch in rx_channels: + self._history[int(ch)].append(filtered_peaks) + + peaks_consistent = self._filter_consistent(filtered_peaks, rx_channels) + peaks_consistent = sorted(peaks_consistent)[: self.config.nMaxPeaks] + + return HTTPMeasurementResponse( + measurement=measurement, + peaks=peaks_consistent, + wavelet=wavelet_env, + spacer_region=spacer_region) + + def reset(self): + self._history.clear() + + def _reconstruct_band(self, coeffs, band_idx, wavelet='db4') -> np.ndarray: + coeffs_keep = [np.zeros_like(c) for c in coeffs] + coeffs_keep[band_idx] = coeffs[band_idx] + return pywt.waverec(coeffs_keep, wavelet) + + def _wavelet_transform(self, series: pd.Series, config: WulpusConfig, wavelet: str = 'db4', select_level: Optional[int] = None) -> np.ndarray: + max_useful_level = pywt.dwt_max_level( + data_len=series.size, filter_len=wavelet) + coeffs = pywt.wavedec(series, wavelet, level=max_useful_level) + sigma = np.median(np.abs(coeffs[-1])) / 0.6745 + threshold = sigma * np.sqrt(2 * np.log(len(series))) + denoised = [pywt.threshold(c, threshold, mode="soft") for c in coeffs] + denoised_signal = pywt.waverec(denoised, wavelet) + if select_level is not None: + select_level = min(select_level, max_useful_level) + return self._reconstruct_band(denoised, select_level, wavelet) + return denoised_signal + + def _upsample(self, series: pd.Series, factor: int) -> pd.Series: + """Upsample a 1-D series by an integer factor, preserving the original index scale. + + If the original index is numeric and monotonic, the new index spans + from original_index[0] to original_index[-1] with evenly spaced points. + Otherwise, it falls back to a 0..N-1 style fractional index. + """ + n = series.size * factor + new_values = resample(series.to_numpy(), n) + idx = series.index + if len(idx) >= 2 and np.issubdtype(idx.dtype, np.number): + start = float(idx[0]) + end = float(idx[-1]) + else: + # Default numeric span 0..(N-1) + start = 0.0 + end = float(series.size - 1) if series.size > 1 else 0.0 + new_index = np.linspace(start, end, n) + return pd.Series(new_values, index=new_index) + + def _envelope(self, signal: np.ndarray) -> np.ndarray: + analytic_signal = hilbert(signal) + return np.abs(analytic_signal) + + def _calc_peaks(self, series_envelope: np.ndarray, config: WulpusConfig, spacer_reflection: np.ndarray) -> List[float]: + peaks, _ = find_peaks(series_envelope, + distance=10 * self.config.upsamplingFactor, + prominence=8) + return peaks.tolist() + + def _calc_spacer_region(self, config: WulpusConfig) -> np.ndarray: + t_spacer = float(0.0) + for sp in self.config.spacers: + if sp['thickness'] > 0 and sp['speedOfSound'] > 0: + t_spacer += (sp['thickness'] / sp['speedOfSound'] / 1e3) + if (t_spacer == 0.0): + return np.array([0, 0]) # invalid region + + t_shift = (config.us_config.start_adcsampl - + config.us_config.start_ppg) * 1e-6 + + t_total = (2 * t_spacer) - t_shift + t_start_tick = t_total * config.us_config.sampling_freq + t_pulse_duration = config.us_config.num_pulses / config.us_config.pulse_freq + t_pulse_duration_ticks = t_pulse_duration * config.us_config.sampling_freq + + return np.array([math.floor(t_start_tick), math.ceil(t_start_tick + t_pulse_duration_ticks)]) + + def _filter_consistent(self, current_peaks: List[float], rx_channels) -> List[float]: + if not current_peaks or not rx_channels: + return current_peaks + consistent: List[float] = [] + for peak in current_peaks: + for ch in rx_channels: + history_lists = list(self._history[int(ch)]) + if not history_lists: + continue + appearances = 0 + for past in history_lists: + if any(abs(peak - p) <= self.config.peakThreshold for p in past): + appearances += 1 + if appearances >= self.config.peakConsistency: + consistent.append(peak) + break # no need to check other channels once accepted + return consistent diff --git a/sw/wulpus/helper.py b/sw/wulpus/helper.py new file mode 100644 index 0000000..42138bc --- /dev/null +++ b/sw/wulpus/helper.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import glob +import inspect +import io +import json +import os +from typing import Any, Generic, Iterable, Iterator, Tuple, TypeVar +from zipfile import ZipFile + +import numpy as np +import pandas as pd +from fastapi import HTTPException + +from wulpus.data_processing import AnalysisConfig +import wulpus as wulpus_pkg +from wulpus.wulpus_config_models import WulpusConfig +from wulpus.data_processing import ANALYSIS_CONFIG_DIR, ANALYSIS_CONFIG_FILENAME + + +def ensure_dir(dir: str) -> None: + os.makedirs(dir, exist_ok=True) + + +def check_if_filereq_is_legitimate(req_name: str, system_dir: str, allowed_ending: str) -> str: + """ Check if the requested file seems plausible. + + Raise HTTPExceptions if invalid. + + Returns: + str: The validated file path. + """ + if os.path.sep in req_name or (os.path.altsep and os.path.altsep in req_name) or len(req_name) > 100: + raise HTTPException(status_code=400, detail="Invalid req_name") + if not req_name.lower().endswith(allowed_ending): + raise HTTPException(status_code=400, detail="Invalid file type") + path = os.path.join(system_dir, req_name) + if not os.path.isfile(path): + raise HTTPException(status_code=404, detail="File not found") + return path + + +def zip_to_dataframe(path: str, ignore_first_frames: int = 0) -> Tuple[pd.DataFrame, WulpusConfig]: + """ + Returns df: DataFrame with log; config: WulpusConfig object + """ + with ZipFile(path, 'r') as zf: + config_raw = json.loads(zf.read('config-0.json').decode('utf-8')) + df_flat = pd.read_parquet(io.BytesIO(zf.read('data.parquet'))) + config = WulpusConfig.model_validate(config_raw) + + # Columns created by save + meta_cols = {'tx', 'rx', 'aq_number', 'tx_rx_id', 'log_version'} + sample_cols = [c for c in df_flat.columns if c not in meta_cols] + + # Ensure numeric order for sample columns (they were saved as strings) + sample_cols = sorted(sample_cols, key=lambda c: int(c)) + + # Rebuild `measurement` as a Series per row + measurements = list( + pd.Series(row[sample_cols].to_numpy(copy=False)) + for _, row in df_flat.iterrows() + ) + + df = pd.DataFrame({ + 'measurement': measurements, + 'tx': df_flat['tx'].tolist(), + 'rx': df_flat['rx'].tolist(), + 'aq_number': df_flat['aq_number'].to_numpy(), + 'tx_rx_id': df_flat['tx_rx_id'].to_numpy() if 'tx_rx_id' in df_flat else np.arange(len(df_flat)), + 'log_version': df_flat['log_version'].to_numpy() if 'log_version' in df_flat else np.full(len(df_flat), 1, dtype=int), + }, index=df_flat.index) + + num_txrx_configs = config.us_config.num_txrx_configs + df = df.iloc[(ignore_first_frames*num_txrx_configs):] + + return df, config + + +def find_latest_measurement_zip(n=1) -> list[str]: + """Return path to the most recent .zip in the package measurements folder. + + Raises FileNotFoundError if the folder or files don't exist. + """ + measurement_dir = os.path.join(os.path.dirname( + inspect.getfile(wulpus_pkg)), 'measurements') + if not os.path.exists(measurement_dir): + raise FileNotFoundError( + f"Measurements directory not found: {measurement_dir}") + zip_files = glob.glob(os.path.join(measurement_dir, '*.zip')) + if not zip_files: + raise FileNotFoundError(f"No zip files found in {measurement_dir}") + zip_files.sort() + return zip_files[:n] + + +def estimate_measurement_duration_seconds(config: WulpusConfig) -> float: + """Estimate duration of a job in seconds.""" + return (config.us_config.num_acqs * config.us_config.meas_period) / 1e6 + + +def concat_dataframes(dfs: list[pd.DataFrame]) -> pd.DataFrame: + """Concatenate multiple DataFrames with same columns.""" + return pd.concat(dfs, axis=0) + + +def get_all_zips_from_folder(folder: str) -> list[str]: + """Return a list of all .zip files in the specified folder.""" + if not os.path.exists(folder): + raise FileNotFoundError(f"Folder not found: {folder}") + zip_files = glob.glob(os.path.join(folder, '*.zip')) + if not zip_files: + raise FileNotFoundError(f"No zip files found in {folder}") + zip_files.sort() + return zip_files + + +def get_saved_analysis_config() -> AnalysisConfig | None: + analysis_config_path = os.path.join( + ANALYSIS_CONFIG_DIR, ANALYSIS_CONFIG_FILENAME) + if not os.path.exists(analysis_config_path): + return None + try: + with open(analysis_config_path, 'r', encoding='utf-8') as f: + config_raw = json.load(f) + config = AnalysisConfig.model_validate(config_raw) + return config + except: + return None + + +T = TypeVar('T') + + +class PassByRef(Generic[T]): + """Simple generic wrapper to carry a mutable reference to a value. + """ + + def __init__(self, value: T): + self.value: T = value + + def set(self, value: T) -> None: + self.value = value + + def __getattr__(self, item: str) -> Any: # runtime delegation + return getattr(self.value, item) + + def __repr__(self) -> str: # pragma: no cover simple convenience + return f"PassByRef({self.value!r})" + + # Optional iteration delegation if underlying is iterable + def __iter__(self) -> Iterator[Any]: # type: ignore[override] + if isinstance(self.value, Iterable): + return iter(self.value) + raise TypeError(f"{type(self.value).__name__} object is not iterable") diff --git a/sw/wulpus/icon.ico b/sw/wulpus/icon.ico new file mode 100644 index 0000000..a4ecb9f Binary files /dev/null and b/sw/wulpus/icon.ico differ diff --git a/sw/wulpus/interface.py b/sw/wulpus/interface.py new file mode 100644 index 0000000..4b3066b --- /dev/null +++ b/sw/wulpus/interface.py @@ -0,0 +1,89 @@ +""" +Generic dongle interface for Wulpus device backends. + +Defines a typed, async-first contract used by concrete implementations: +- Serial-based dongle (dongle.py) +- Mock dongle (dongle_mock.py) +- Direct (e.g., BLE) dongle (dongle_direct.py) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Optional, List, Tuple +from typing_extensions import TypedDict + +import numpy as np +from serial.tools.list_ports_common import ListPortInfo +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from wulpus.wulpus import Wulpus + + +class ConnectionType(Enum): + SERIAL = "serial" + BLE = "ble" + + +class ConnectionOption(TypedDict): + device: str + description: str + type: ConnectionType + + +class DongleInterface(ABC): + """Abstract base class for Wulpus dongles.""" + + acq_length: int + + def __init__(self, *, acq_length: int = 400) -> None: + self.acq_length = acq_length + + @abstractmethod + async def get_available(self) -> List[ConnectionOption]: + """Discover and return available connection options.""" + raise NotImplementedError + + @abstractmethod + async def connect( + self, + device: Optional[ListPortInfo] = None, + device_str: Optional[str] = None, + ) -> bool: + """Open the device connection.""" + raise NotImplementedError + + @abstractmethod + async def close(self) -> None: + """Close the device connection, if open.""" + raise NotImplementedError + + @abstractmethod + async def send_config(self, conf_bytes_pack: bytes) -> bool: + """Send a configuration package to the device.""" + raise NotImplementedError + + @abstractmethod + async def receive_data( + self, + wulpus: Wulpus, + acq_length: int = 400, + ) -> Optional[Tuple[np.ndarray, int, int]]: + """ + Receive a single data acquisition frame. + acquisition_running is dict {"running": bool} + Returns a tuple of (rf_data int16 array, acq_number, tx_rx_id) or None. + """ + raise NotImplementedError + + @abstractmethod + def get_status(self) -> str: + """Human-readable backend status info.""" + raise NotImplementedError + + @abstractmethod + def get_connection_endpoint(self) -> str: + """connection endpoint string (used for connection)""" + raise NotImplementedError diff --git a/sw/wulpus/interface_direct.py b/sw/wulpus/interface_direct.py new file mode 100644 index 0000000..2c044f8 --- /dev/null +++ b/sw/wulpus/interface_direct.py @@ -0,0 +1,243 @@ +from __future__ import annotations +import asyncio +import re +from typing import Callable, Union, Optional, List +import time + +import numpy as np + +from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.exc import BleakError +from serial.tools.list_ports_common import ListPortInfo +from typing import TYPE_CHECKING +from .interface import DongleInterface, ConnectionType, ConnectionOption +if TYPE_CHECKING: + from wulpus.wulpus import Wulpus + +# Standard Nordic UART Service (NUS) UUIDs +NUS_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +NUS_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" # From PC to Wulpus +NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" # From Wulpus to PC +WULPUS_NAME_PATTERN = re.compile(r"WULPUS_PROBE_\d+") + + +class WulpusDongleDirect(DongleInterface): + """ + Class representing the Wulpus via direct Bluetooth connection + """ + + def __init__(self, disconnected_callback: Optional[Callable[[BleakClient], None]] = None) -> None: + super().__init__() + self._devices: list[BLEDevice] = [] + self._bleak_client: Union[BleakClient, None] = None + self._data_queue: Optional["asyncio.Queue[bytes]"] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._disconnected_callback = disconnected_callback + self._last_frame_time: Optional[float] = None + + async def get_available(self) -> List[ConnectionOption]: + """ + Get a list of available devices. + + Returns: + list[dict[str, object]]: A list of dicts with keys "device", "description" and "type" (ConnectionType). + """ + try: + devices = await BleakScanner.discover() + devices = [ + d for d in devices if d.name and WULPUS_NAME_PATTERN.fullmatch(d.name)] + self._devices = sorted(devices, key=lambda d: d.name) + return [ + {"device": str(d.address), "description": str( + d.name), "type": ConnectionType.BLE} + for d in self._devices + ] + except (OSError, BleakError): + print("Error during Bluetooth discovery - Adapter probably disabled") + return [] + + async def connect(self, device: Optional[ListPortInfo] = None, device_str: Optional[str] = None) -> bool: + """ + Open the device connection. + """ + if (device): + raise ValueError( + "Device works only with Serial USB Dongle (not direct BLE connection)") + + target_address: Optional[str] = None + if device_str: + target_address = device_str + # If a previous discovery returned devices, allow selecting by index via device.device if provided as ListPortInfo-like. + # But for BLE we'll take device_str (MAC) as the source of truth. + + if not target_address: + # Try to pick the first discovered WULPUS device + if not self._devices: + await self.get_available() + if not self._devices: + print("Error: no BLE WULPUS device found.") + return False + target = self._devices[0] + target_address = target.address + + try: + # Bind to the current running loop and create queue here to avoid cross-loop issues + self._loop = asyncio.get_running_loop() + # Reasonable buffer to absorb short bursts without unbounded growth + self._data_queue = asyncio.Queue(maxsize=1000) + self._bleak_client = BleakClient( + target_address, disconnected_callback=self._disconnected_callback) + await self._bleak_client.connect() + await self._bleak_client.start_notify(NUS_TX_CHAR_UUID, self._notification_handler) + return True + except (BleakError, OSError, TimeoutError) as e: + print("Error while trying to open BLE device:", e) + return False + + def _notification_handler(self, _sender: int, data: bytearray): + """ + Handle incoming notifications from the BLE device. + """ + # Ensure we enqueue on the correct event loop thread (Bleak callback may be on another thread) + if self._loop and self._data_queue is not None: + def _enqueue(): + try: + if self._data_queue.full(): + # Drop oldest to make space + self._data_queue.get_nowait() + self._data_queue.put_nowait(bytes(data)) + except (asyncio.QueueEmpty, asyncio.QueueFull): + # Swallow to avoid noisy exceptions in callback thread + pass + self._loop.call_soon_threadsafe(_enqueue) + + async def close(self): + """ + Close the device connection. + """ + try: + if self._bleak_client and self._bleak_client.is_connected: + await self._bleak_client.stop_notify(NUS_TX_CHAR_UUID) + try: + await self._bleak_client.disconnect() + except BleakError: + pass + except BleakError as e: + print("Error while trying to close BLE device:", e) + finally: + self._bleak_client = None + self._data_queue = None + self._loop = None + + async def send_config(self, conf_bytes_pack: bytes): + """ + Send a configuration package to the device. + """ + if not self._bleak_client: + print("Error: BLE client is not connected.") + return False + + try: + await self._bleak_client.write_gatt_char(NUS_RX_CHAR_UUID, conf_bytes_pack) + return True + except BleakError as e: + print("Error while trying to send config to BLE device:", e) + return False + + async def receive_data(self, wulpus: Wulpus, acq_length: int = 400): + """ + Receives and processes data from the Wulpus device. + + The function assembles a complete data frame from incoming BLE packets. + A frame is composed of 4 packets, totaling 804 bytes. + - The first packet is 202 bytes, with the first byte being a start-of-frame marker (0xFF). + The last byte of this packet is ignored. + - The following three packets are each 201 bytes. + + The assembled 804-byte frame has the following structure: + - 1-byte start of frame marker (0xFF) + - 1-byte tx_rx_id + - 2-byte acquisition number (little-endian) + - 800-byte RF data payload (400 samples of 16-bit signed integers) + """ + if not self._bleak_client or not self._bleak_client.is_connected: + print("Error: BLE client is not connected.") + return None, None, None + + if self._data_queue is None: + print("Error: Data queue not initialized.") + return None, None, None + + frame_buffer = bytearray() + + while wulpus.get_acquisition_running(): + try: + # Wait for data with a timeout to allow checking the acquisition_running flag + data = await asyncio.wait_for(self._data_queue.get(), timeout=0.1) + except asyncio.TimeoutError: + continue + + # Start of a new frame + if len(data) == 202 and data[0] == 0xFF: + if len(frame_buffer) > 0: + print( + f"Warning: Incomplete frame discarded ({len(frame_buffer)} bytes)") + # The first packet is 202 bytes, but we discard the last byte which is likely garbage due to a firmware bug. + frame_buffer = bytearray(data[:201]) + continue + + # Subsequent packets of the current frame + if frame_buffer and len(data) == 201: + frame_buffer.extend(data) + + # A full frame has been received + if len(frame_buffer) == 804: + # Frame structure: [0xFF, tx_rx_id, acq_nr_L, acq_nr_H, data...] + tx_rx_id = frame_buffer[1] + acq_nr = int.from_bytes(frame_buffer[2:4], 'little') + # The actual RF data starts after the 4-byte header + rf_arr = np.frombuffer(frame_buffer[4:], dtype=' 804: + print( + f"Warning: Oversized frame discarded ({len(frame_buffer)} bytes)") + frame_buffer = bytearray() + + return None, None, None + + def get_status(self): + if self._bleak_client and self._bleak_client.is_connected: + addr = getattr(self._bleak_client, 'address', None) + return f"connected to {addr}" if addr else "connected" + return "not connected" + + def get_connection_endpoint(self) -> str: + if self._bleak_client and self._bleak_client.is_connected: + addr = getattr(self._bleak_client, 'address', None) + return addr if addr else "" + return "" + + +if __name__ == "__main__": + async def _main(): + dongle = WulpusDongleDirect() + print(await dongle.get_available()) + asyncio.run(_main()) diff --git a/sw/wulpus/interface_mock.py b/sw/wulpus/interface_mock.py new file mode 100644 index 0000000..81a7c12 --- /dev/null +++ b/sw/wulpus/interface_mock.py @@ -0,0 +1,60 @@ +from __future__ import annotations +import asyncio +from serial.tools.list_ports_common import ListPortInfo +import numpy as np +from wulpus.interface_usb import WulpusDongleUsb +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from wulpus.wulpus import Wulpus + + +class WulpusDongleMock(WulpusDongleUsb): + """ + Class representing the Wulpus dongle (mock implementation). + """ + + def __init__(self, port: str = '', timeout_write: int = 3, baudrate: int = 4000000): + super().__init__(port=port, timeout_write=timeout_write, baudrate=baudrate) + self.acq_num = 0 + self.acq_length = 400 + + async def connect(self, device: ListPortInfo = None, device_str: str = None): + """ + Open the device connection. + """ + self.acq_num = 0 + return True + + async def close(self): + """ + Close the device connection. + """ + return + + async def send_config(self, conf_bytes_pack: bytes): + """ + Send a configuration package to the device. + """ + print("Configuration sent:", conf_bytes_pack.hex()) + self.acq_num = 0 + return True + + async def receive_data(self, wulpus: Wulpus, acq_length: int = 400): + """ + Mock: Return random data with the same structure as the original (async). + """ + self.acq_length = acq_length + await asyncio.sleep(0.2) # Simulate some delay + rf_arr = np.random.randint( + 1, 1001, size=self.acq_length, dtype=" str: + return "mocked_endpoint" diff --git a/sw/wulpus/interface_usb.py b/sw/wulpus/interface_usb.py new file mode 100644 index 0000000..6c2ea57 --- /dev/null +++ b/sw/wulpus/interface_usb.py @@ -0,0 +1,118 @@ +""" +Serial dongle implementation conforming to DongleInterface. +""" +from __future__ import annotations +import asyncio +from typing import Optional, List + +import numpy as np +import serial +from serial.tools.list_ports import comports +from serial.tools.list_ports_common import ListPortInfo +from typing import TYPE_CHECKING +from .interface import DongleInterface, ConnectionType, ConnectionOption + +if TYPE_CHECKING: + from wulpus.wulpus import Wulpus + + +class WulpusDongleUsb(DongleInterface): + """Serial implementation of the Wulpus dongle.""" + + def __init__(self, port: str = '', timeout_write: int = 3, baudrate: int = 4000000) -> None: + super().__init__() + + # Serial port setup + self.__ser__ = serial.Serial() + self.__ser__.port = port + self.__ser__.baudrate = baudrate + self.__ser__.bytesize = serial.EIGHTBITS + self.__ser__.parity = serial.PARITY_NONE + self.__ser__.stopbits = serial.STOPBITS_ONE + self.__ser__.timeout = None + self.__ser__.xonxoff = False + self.__ser__.rtscts = False + self.__ser__.dsrdtr = False + self.__ser__.writeTimeout = timeout_write + + self._ports: list[ListPortInfo] = [] + + async def get_available(self) -> List[ConnectionOption]: + ports = comports() + self._ports = sorted(ports) + return [ + {"device": str(p.device), "description": str( + p.description), "type": ConnectionType.SERIAL} + for p in self._ports + ] + + async def connect(self, device: Optional[ListPortInfo] = None, device_str: Optional[str] = None) -> bool: + if self.__ser__.is_open: + return True + if device_str is not None: + self.__ser__.port = device_str + if device is not None: + self.__ser__.port = device.device + if not self.__ser__.port: + print("Error: no serial port specified.") + return False + try: + self.__ser__.open() + return True + except serial.SerialException: + print("Error while trying to open serial port ", + str(self.__ser__.port)) + return False + + async def close(self) -> None: + if not self.__ser__.is_open: + return + try: + self.__ser__.close() + except serial.SerialException: + print("Error while trying to close serial port ", + str(self.__ser__.port)) + + async def send_config(self, conf_bytes_pack: bytes) -> bool: + if not self.__ser__.is_open: + print("Error: serial port is not open.") + return False + self.__ser__.flushInput() + self.__ser__.flushOutput() + self.__ser__.write(conf_bytes_pack) + return True + + def _get_rf_data_and_info__(self, bytes_arr: bytes): + rf_arr = np.frombuffer(bytes_arr[7:], dtype=' str: + return f"connected to {self.__ser__.port}" if self.__ser__.is_open else "not connected" + + def get_connection_endpoint(self) -> str: + return self.__ser__.port if self.__ser__.is_open else "" + + +if __name__ == "__main__": + async def _main(): + d = WulpusDongleUsb() + print(await d.get_available()) + asyncio.run(_main()) diff --git a/sw/wulpus/main.py b/sw/wulpus/main.py new file mode 100644 index 0000000..5e24812 --- /dev/null +++ b/sw/wulpus/main.py @@ -0,0 +1,365 @@ +import asyncio +import inspect +import json +import os +import time +import subprocess +from typing import List, Optional, Union + +import uvicorn +from fastapi import (FastAPI, HTTPException, + WebSocket, WebSocketDisconnect, Request) +from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.staticfiles import StaticFiles +from wulpus.wulpus_model import Status +from wulpus.data_processing import AnalysisConfig, MeasurementProcessor +from wulpus.series import series_loop, SeriesConfig, SeriesStartRequest +from wulpus.wulpus_api import CONFIG_FILE_EXTENSION, DATA_FILE_EXTENSION +from wulpus.helper import PassByRef, check_if_filereq_is_legitimate, ensure_dir, estimate_measurement_duration_seconds, get_saved_analysis_config +from wulpus.websocket_manager import WebsocketManager +from wulpus.wulpus_config_models import (ConDev, MultiWulpusConfig, TxRxConfig, UsConfig, + WulpusConfig) +from wulpus.data_processing import ANALYSIS_CONFIG_DIR, ANALYSIS_CONFIG_FILENAME +from pydantic import BaseModel, Field +from wulpus.wulpus_mock import WulpusMock +import wulpus as wulpus_pkg +from wulpus.wulpus import Wulpus + +THIS_DIR = os.path.dirname(inspect.getfile(wulpus_pkg)) +MEASUREMENTS_DIR = os.path.join(THIS_DIR, 'measurements') +CONFIG_DIR = os.path.join(THIS_DIR, 'configs') +FRONTEND_DIR = os.path.join(THIS_DIR, 'production-frontend') + +wulpi = [Wulpus()] # List of Wulpus instances, can be expanded later +wulpus_mocks = [WulpusMock(), WulpusMock(1)] + +processor = MeasurementProcessor(get_saved_analysis_config()) + +manager = WebsocketManager(wulpi, processor) +app = FastAPI() +app.state.send_data_task = None # type: Optional[asyncio.Task] +app.state.series_task = None # type: Optional[asyncio.Task] +# type: PassByRef[Optional[SeriesConfig]] +app.state.series_info = PassByRef(None) + + +@app.post("/api/start") +async def start(config: Union[WulpusConfig, MultiWulpusConfig]): + ready_wulpus = 0 + for w in manager.get_wulpus(): + if w.get_status()["status"] == Status.READY: + ready_wulpus += 1 + w.set_config(config) + if ready_wulpus == 0: + raise HTTPException( + status_code=400, detail="No Wulpus is ready!") + for w in manager.get_wulpus(): + if w.get_status()["status"] == Status.READY: + await w.start() + else: + manager.remove_wulpus(w.wulpus_id) + return {"ok": "ok"} + + +@app.post("/api/stop") +def stop(): + for w in manager.get_wulpus(): + w.stop() + processor.reset() + return {"ok": "ok"} + + +@app.post("/api/series/start") +async def start_series(req: SeriesStartRequest): + """Start a repeating measurement series at a fixed interval (seconds).""" + # Reject if a series is already running + if app.state.series_task and not app.state.series_task.done(): + raise HTTPException(status_code=400, detail="Series already running") + + # Check if duration of single measurement is less than interval + est = estimate_measurement_duration_seconds(req.config) + if est >= req.interval_seconds: + raise HTTPException( + status_code=400, detail=f"Estimated measurement duration {est:.2f}s exceeds or equals interval {req.interval_seconds}s") + + app.state.series_info.value = SeriesConfig( + active=True, + config=req.config, + interval_seconds=req.interval_seconds, + number=req.number, + progress_count=0 + ) + + processor.reset() + app.state.series_task = asyncio.create_task( + series_loop(app.state.series_info, start)) + return {"ok": True} + + +@app.post("/api/series/stop") +async def stop_series(): + if app.state.series_task: + app.state.series_task.cancel() + app.state.series_info.value = None + return stop() + + +@app.get("/api/connections") +async def get_connections(): + results = [] + for w in manager.get_wulpus(): + options = await w.get_connection_options() + result = {"options": options} + if hasattr(w, 'id'): + result["wulpus_id"] = w.wulpus_id + results.append(result) + return results + + +@app.post("/api/connect") +async def connect_single(conf: ConDev): + for w in manager.get_wulpus(): + if (w.get_status()["status"] == Status.NOT_CONNECTED): + await w.connect(conf.con_dev) + return + # All existing Wulpus are connected, create a new one + new_wulpus = Wulpus(manager.find_free_id()) + new_wulpus.set_new_measurement_event(app.state.new_data_event) + manager.add_wulpus(new_wulpus) + await new_wulpus.connect(conf.con_dev) + + +@app.post("/api/connect/{wulpus_id}") +async def connect_specific(wulpus_id: int, conf: ConDev): + wulpus = manager.get_wulpus(wulpus_id) + if (len(wulpus) == 1): + w = wulpus[0] + await w.connect(conf.con_dev) + else: + new_wulpus = Wulpus(wulpus_id) + new_wulpus.set_new_measurement_event(app.state.new_data_event) + manager.add_wulpus(new_wulpus) + await new_wulpus.connect(conf.con_dev) + + +@app.post("/api/disconnect") +async def disconnect(conf: ConDev): + for w in manager.get_wulpus(): + if conf.con_dev == w.get_status()["endpoint"]: + await w.disconnect() + if (len(manager.get_wulpus()) > 1): + manager.remove_wulpus(w.wulpus_id) + break + + +@app.post("/api/disconnect/all") +async def disconnect_all(): + for w in manager.get_wulpus(): + await w.disconnect() + if (len(manager.get_wulpus()) > 1): + manager.remove_wulpus(w.wulpus_id) + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + # Task to periodically broadcast status updates to all clients + asyncio.create_task(manager.task_send_status( + websocket, app.state.series_info)) + + # Task to broadcast measurements to all clients + if app.state.send_data_task is None or app.state.send_data_task.done(): + app.state.new_data_event = asyncio.Event() + new_data_event = app.state.new_data_event + + for w in manager.get_wulpus(): + w.set_new_measurement_event(new_data_event) + + app.state.send_data_task = asyncio.create_task( + manager.task_broadcast_data(new_data_event)) + + await manager.send_data() + try: + while True: + data = await websocket.receive_text() + await manager.broadcast_text(f"Client says: {data}") + except WebSocketDisconnect: + manager.disconnect(websocket) + await manager.broadcast_text("A Client left the chat") + + +@app.get("/api/logs", response_model=List[str]) +def list_logs() -> List[str]: + """Return list of saved measurement files (npz) relative names.""" + ensure_dir(MEASUREMENTS_DIR) + try: + files = [f for f in os.listdir( + MEASUREMENTS_DIR) if f.lower().endswith(DATA_FILE_EXTENSION)] + files.sort(reverse=True) + return files + except FileNotFoundError: + return [] + + +@app.get("/logs/{filename}") +def download_log(filename: str): + """Download a specific measurement file by filename.""" + ensure_dir(MEASUREMENTS_DIR) + filepath = check_if_filereq_is_legitimate( + filename, MEASUREMENTS_DIR, DATA_FILE_EXTENSION) + return FileResponse(filepath, media_type='application/octet-stream', filename=filename) + + +@app.get("/api/configs", response_model=List[str]) +def list_configs() -> List[str]: + """Return list of saved config files (json) relative names.""" + ensure_dir(CONFIG_DIR) + try: + files = [f for f in os.listdir( + CONFIG_DIR) if f.lower().endswith(CONFIG_FILE_EXTENSION)] + files.sort(reverse=True) + return files + except FileNotFoundError: + return [] + + +@app.get("/api/configs/{filename}") +def download_config(filename: str): + """Download a specific config file by filename.""" + ensure_dir(CONFIG_DIR) + filepath = check_if_filereq_is_legitimate( + filename, CONFIG_DIR, CONFIG_FILE_EXTENSION) + return FileResponse(filepath, media_type='application/octet-stream', filename=filename) + + +@app.post("/api/configs") +async def save_config(config: WulpusConfig, name: Optional[str] = None): + """Save the provided config JSON to a file in the configs directory.""" + ensure_dir(CONFIG_DIR) + # derive safe base filename + if name is None or len(name.strip()) == 0: + name = "wulpus-config-" + \ + time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + # very simple sanitization + if os.path.sep in name or (os.path.altsep and os.path.altsep in name) or len(name) > 100: + raise HTTPException(status_code=400, detail="Invalid name") + base = os.path.join(CONFIG_DIR, name) + filename = base + ".json" + # avoid overwriting existing files + suffix = 1 + while os.path.exists(filename): + filename = f"{base}_{suffix}.json" + suffix += 1 + try: + with open(filename, 'w', encoding='utf-8') as f: + json.dump(config.model_dump(), f, ensure_ascii=False, indent=2) + return {"filename": os.path.basename(filename)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@app.delete("/api/configs/{filename}") +def delete_config(filename: str): + """Delete a specific config file by filename.""" + ensure_dir(CONFIG_DIR) + try: + filepath = check_if_filereq_is_legitimate( + filename, CONFIG_DIR, '.json') + os.remove(filepath) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@app.post("/api/activate-mock") +def activate_mock(): + for w in manager.get_wulpus(): + w.stop() + for w in wulpus_mocks: + w.set_new_measurement_event(app.state.new_data_event) + manager.set_wulpus(wulpus_mocks) + processor.reset() + return {"ok": "ok"} + + +@app.post("/api/deactivate-mock") +def deactivate_mock(): + for w in wulpus_mocks: + w.stop() + manager.set_wulpus(wulpi) + processor.reset() + return {"ok": "ok"} + + +@app.post("/api/replay/{filename}") +async def replay_file(filename: str): + # Build a minimal default config: one empty TxRxConfig and a UsConfig with its own defaults + default_config = WulpusConfig( + tx_rx_config=[TxRxConfig()], us_config=UsConfig()) + for w in manager.get_wulpus(): + w.stop() + for w in wulpus_mocks: + w.set_new_measurement_event(app.state.new_data_event) + ensure_dir(MEASUREMENTS_DIR) + manager.set_wulpus(wulpus_mocks) + filepath = check_if_filereq_is_legitimate( + filename, MEASUREMENTS_DIR, DATA_FILE_EXTENSION) + for w in wulpus_mocks: + w.set_config(default_config) + w.set_replay_file(filepath) + processor.reset() + await asyncio.gather(*(w.start() for w in wulpus_mocks)) + + +@app.get("/api/analyzeConfig", response_model=Optional[AnalysisConfig]) +def get_analyze_config(): + return processor.get_analyze_config() + + +@app.post("/api/analyzeConfig", response_model=Optional[AnalysisConfig]) +def set_analyze_config(config: AnalysisConfig): + ensure_dir(ANALYSIS_CONFIG_DIR) + filename = os.path.join(ANALYSIS_CONFIG_DIR, ANALYSIS_CONFIG_FILENAME) + with open(filename, 'w', encoding='utf-8') as f: + json.dump(config.model_dump(), f, ensure_ascii=False, indent=2) + processor.set_analyze_config(config) + return get_analyze_config() + + +@app.get("/api/version", response_model=Optional[AnalysisConfig]) +def get_version() -> str: + path = os.path.join(THIS_DIR, "version.txt") + if os.path.isfile(path): + with open(path, "r", encoding="utf-8") as f: + return PlainTextResponse(f.read().strip()) + else: + text = "unknown" + try: + commit_hash = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=THIS_DIR, stderr=subprocess.DEVNULL + ).decode("utf-8").strip() + commit_date = subprocess.check_output( + ["git", "show", "-s", "--format=%cd", "--date=iso-strict", "HEAD"], + cwd=THIS_DIR, + stderr=subprocess.DEVNULL, + ).decode("utf-8").strip().split("T")[0] + text = f"dev: {commit_hash} from {commit_date}" + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return PlainTextResponse(text) + + +app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, + 'assets')), name="assets") + + +@app.get("/{full_path:path}") +async def frontend_fallback(full_path: str, request: Request): + index_path = os.path.join(FRONTEND_DIR, "index.html") + return FileResponse(index_path) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/sw/wulpus/main.spec b/sw/wulpus/main.spec new file mode 100644 index 0000000..0fdb634 --- /dev/null +++ b/sw/wulpus/main.spec @@ -0,0 +1,52 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import collect_submodules + +datas = [] +hiddenimports = [] +datas += [('./production-frontend', "wulpus/production-frontend"), ('./icon.ico', ".")] +hiddenimports += collect_submodules('wulpus') + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='main', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon="./icon.ico" +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='main', +) diff --git a/sw/wulpus/measurements/.gitignore b/sw/wulpus/measurements/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/sw/wulpus/measurements/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/sw/wulpus/plot_helpers.py b/sw/wulpus/plot_helpers.py new file mode 100644 index 0000000..82a697b --- /dev/null +++ b/sw/wulpus/plot_helpers.py @@ -0,0 +1,124 @@ +from __future__ import annotations +import os +import glob +import inspect +from datetime import datetime +import numpy as np +import pandas as pd +import wulpus as wulpus_pkg +from typing import Tuple, List, Optional +from wulpus.helper import zip_to_dataframe + + +def flatten_df_measurements(df: pd.DataFrame, sample_crop: Optional[int] = None) -> Tuple[pd.DataFrame, List[str]]: + """Convert a DataFrame with a 'measurement' column (each row an array/Series) + into a flat DataFrame where each sample is its own numeric column. + + Returns (flattened_df, measurement_column_names). + measurement columns are named as decimal strings: '0','1',... matching sample indices. + """ + if 'measurement' not in df.columns: + raise ValueError("DataFrame does not contain 'measurement' column") + + # Build a DataFrame of Series so ragged lengths are allowed + series_list = [] + for m in df['measurement']: + arr = np.asarray(m, dtype=float) + if sample_crop is not None: + arr = arr[:sample_crop] + # pd.Series will allow varying lengths when assembled into DataFrame + series_list.append(pd.Series(arr)) + + measurement_expanded = pd.DataFrame(series_list, index=df.index) + # Name the sample columns as strings '0','1',... + measurement_expanded.columns = [ + str(i) for i in range(measurement_expanded.shape[1])] + + flattened_df = pd.concat( + [df.drop(columns=['measurement']), measurement_expanded], axis=1) + # Ensure column names are strings for downstream parquet compat + flattened_df.columns = [str(c) for c in flattened_df.columns] + + meas_cols: List[str] = [c for c in measurement_expanded.columns] + return flattened_df, meas_cols + + +def format_time_index_to_local(dt_index: pd.DatetimeIndex, tz: Optional[str] = None) -> pd.DatetimeIndex: + """Convert a DatetimeIndex to a timezone-aware index in tz (or local timezone if tz is None). + + The function assumes integer/us timestamps should already have been converted by caller. + """ + local_tz = datetime.now().astimezone().tzinfo if tz is None else tz + try: + if dt_index.tz is None: + dt_index = dt_index.tz_localize('UTC').tz_convert(local_tz) + else: + dt_index = dt_index.tz_convert(local_tz) + except Exception: + # best-effort fallback + try: + dt_index = pd.to_datetime(dt_index).tz_localize( + 'UTC').tz_convert(local_tz) + except Exception: + pass + return dt_index + + +def format_time_xticks(ax, index, num_ticks: int = 10, rotation: int = 45, tz: Optional[str] = None): + """Set x-ticks on ax using `index` (pandas Index of acquisition timestamps). + + Accepts integer microsecond timestamps or datetime-like Index objects. + Chooses up to `num_ticks` evenly spaced tick positions and formats labels HH:MM:SS. + """ + n = len(index) + if n == 0: + return + num_ticks = min(num_ticks, n) + if num_ticks <= 1: + ax.set_xticks([0]) + ax.set_xticklabels(['Single acquisition']) + return + + frame_indices = np.linspace(0, n - 1, num_ticks, dtype=int) + idx_vals = index.values + if np.issubdtype(idx_vals.dtype, np.integer): + dt_index = pd.to_datetime(idx_vals, unit='us') + else: + dt_index = pd.to_datetime(index) + + dt_index = format_time_index_to_local(dt_index, tz) + labels = [dt.strftime('%H:%M:%S') for dt in dt_index[frame_indices]] + ax.set_xticks(frame_indices) + ax.set_xticklabels(labels, rotation=rotation) + + +def imshow_with_time( + ax, + plot_data: np.ndarray, + index, + cmap='viridis', + interpolation='hamming', + norm=None, + num_ticks=10, + tz: Optional[str] = None, + colorbar_label: str = 'ADC digital code', + add_colorbar: bool = True, +): + """Convenience: imshow the plot_data on ax, add colorbar, and set time xticks using index. + + plot_data shape should be (n_samples, n_acq) i.e. rows=samples, cols=acquisitions. + """ + im = ax.imshow( + plot_data, + aspect='auto', + cmap=cmap, + interpolation=interpolation, + norm=norm, + origin='lower', + ) + fig = ax.figure + if add_colorbar: + fig.colorbar(im, ax=ax, label=colorbar_label) + format_time_xticks(ax, index=index, num_ticks=num_ticks, + rotation=45, tz=tz) + return im diff --git a/sw/wulpus/production-frontend/.gitignore b/sw/wulpus/production-frontend/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/sw/wulpus/production-frontend/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/sw/wulpus/requirements.txt b/sw/wulpus/requirements.txt new file mode 100644 index 0000000..a924e1e --- /dev/null +++ b/sw/wulpus/requirements.txt @@ -0,0 +1,14 @@ +numpy~=1.23 +scipy~=1.9 +pyserial~=3.5 +pydantic~=2.11 +ipympl~=0.9.3 +matplotlib~=3.5 +websockets~=15.0 +pyinstaller~=6.15 +fastapi[standard]~=0.116 +pandas~=2.3 +tables~=3.9 +pyarrow~=21.0 +bleak~=1.1 +PyWavelets~=1.6.0 \ No newline at end of file diff --git a/sw/wulpus/series.py b/sw/wulpus/series.py new file mode 100644 index 0000000..735ed17 --- /dev/null +++ b/sw/wulpus/series.py @@ -0,0 +1,37 @@ +import asyncio +from typing import Callable +from wulpus.helper import PassByRef +from wulpus.wulpus_config_models import WulpusConfig +from pydantic import BaseModel, Field + + +class SeriesStartRequest(BaseModel): + interval_seconds: int = Field( + gt=0, description="Interval in seconds between triggered measurements") + config: WulpusConfig + number: int = Field( + gt=0, description="Number of times to repeat the measurement in this series") + + +class SeriesConfig(BaseModel): + active: bool = Field(default=False) + config: WulpusConfig + interval_seconds: int = Field(default=10, gt=0) + number: int = Field(default=0, gt=0) + progress_count: int = Field(default=0) + + +async def series_loop(series_config: PassByRef[SeriesConfig], + start_job_callback: Callable[[WulpusConfig], object]): + config = series_config.value + # Access underlying value via .value for static typing clarity + if not config.active: + return + while True: + await start_job_callback(config.config) + config.progress_count += 1 + if config.progress_count < config.number: + await asyncio.sleep(config.interval_seconds) + else: + break + config.active = False diff --git a/sw/wulpus/websocket_manager.py b/sw/wulpus/websocket_manager.py new file mode 100644 index 0000000..5404dfb --- /dev/null +++ b/sw/wulpus/websocket_manager.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import TYPE_CHECKING, Any, Callable, Optional, Union + +from fastapi import WebSocket, WebSocketDisconnect +from fastapi.encoders import jsonable_encoder +from fastapi.websockets import WebSocketState +from wulpus.data_processing import MeasurementProcessor +from wulpus.helper import PassByRef +from wulpus.series import SeriesConfig + +if TYPE_CHECKING: + from wulpus.wulpus import Wulpus + + +class WebsocketManager: + def __init__(self, _wulpus: list[Wulpus], _processor: MeasurementProcessor = MeasurementProcessor()): + self.active_connections: list[WebSocket] = [] + self._processor = _processor + self.set_wulpus(_wulpus) + + def set_wulpus(self, wulpus: Union[list[Wulpus], Wulpus]): + if isinstance(wulpus, list): + self.wulpus = list(wulpus) + else: + self.wulpus = [wulpus] + + def add_wulpus(self, wulpus: Wulpus): + self.wulpus.append(wulpus) + return wulpus + + def remove_wulpus(self, wulpus_id: int): + wulpus = self._select_wulpus(wulpus_id) + if wulpus: + print(f"Removing Wulpus with id {wulpus_id}") + self.wulpus.remove(wulpus) + print("Removing done") + + def _select_wulpus(self, wulpus_id: int) -> Optional[Wulpus]: + filtered_wulpus = list( + filter(lambda w: w.wulpus_id == wulpus_id, self.wulpus)) + if (len(filtered_wulpus) > 1): + raise ValueError( + f"Multiple Wulpus instances with id {wulpus_id} found.") + elif (len(filtered_wulpus) == 0): + print(f"No Wulpus instance with id {wulpus_id} found.") + return None + else: + return filtered_wulpus[0] + + def get_wulpus(self, wulpus_id: Optional[int] = None) -> list[Wulpus]: + """Get Wulpus instances + If ID is provided, a single instance with this id will be returned in a list. + If no ID is provided, all instances will be returned. + """ + if wulpus_id is None: + return self.wulpus + elif self._select_wulpus(wulpus_id) is not None: + return [self._select_wulpus(wulpus_id)] + else: + return [] + + async def exec_wulpus_function(self, func: Callable[[Wulpus], Any]): + results = [] + for w in self.wulpus: + result = func(w) + if inspect.iscoroutine(result): + result = await result + results.append(result) + return results + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def send_single_client(self, message: str, websocket: WebSocket): + await websocket.send_text(message) + + async def broadcast_text(self, message: str): + for connection in self.active_connections: + try: + await connection.send_text(message) + except RuntimeError: + self.disconnect(connection) + + async def broadcast_json(self, message): + # Ensure message is JSON serializable (handles pydantic models, numpy types, etc.) + payload = jsonable_encoder(message) + for connection in self.active_connections: + try: + await connection.send_json(payload) + except (RuntimeError, WebSocketDisconnect): # Client disconnected + self.disconnect(connection) + + async def task_send_status(self, websocket: WebSocket, series_info: PassByRef[Optional[SeriesConfig]]): + old_status: Union[list[dict[str, Any]], None] = None + try: + while websocket.application_state == WebSocketState.CONNECTED: + statuses = [] + for w in self.wulpus: + status = w.get_status() + status = {**status, "wulpus_id": w.wulpus_id} + if series_info.value: + status = {**status, "series": series_info.value} + statuses.append(status) + if statuses != old_status: + await websocket.send_json(jsonable_encoder(statuses)) + old_status = statuses + await asyncio.sleep(0.05) + except (RuntimeError, WebSocketDisconnect): # Client disconnected + return + + async def task_broadcast_data(self, new_measurement_event: asyncio.Event): + while True: + await new_measurement_event.wait() + new_measurement_event.clear() + await self.send_data() + + async def send_data(self): + for w in self.wulpus: + data = w.get_latest_frame() + if data is not None: + processed = self._processor.process_measurement( + data, w.get_config()) + processed = {**dict(processed), "wulpus_id": w.wulpus_id} + await self.broadcast_json(processed) + + def find_free_id(self) -> int: + existing_ids = {w.wulpus_id for w in self.wulpus} + new_id = 0 + while new_id in existing_ids: + new_id += 1 + return new_id diff --git a/sw/wulpus/wulpus.py b/sw/wulpus/wulpus.py new file mode 100644 index 0000000..c8d0f53 --- /dev/null +++ b/sw/wulpus/wulpus.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import asyncio +import inspect +import io +import os +import time +from enum import IntEnum +from typing import Union +from zipfile import ZipFile + +import numpy as np +import pandas as pd +from typing_extensions import TypedDict +from wulpus.wulpus_model import Measurement, Status +from wulpus.helper import ensure_dir +from wulpus.interface import DongleInterface +from wulpus.interface_direct import WulpusDongleDirect +from wulpus.interface_usb import WulpusDongleUsb +from wulpus.wulpus_api import (DATA_FILE_EXTENSION, gen_conf_package, + gen_restart_package) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from wulpus.wulpus_config_models import WulpusConfig + +import wulpus as wulpus_pkg + + +class Wulpus: + def __init__(self, wulpus_id=0): + self.wulpus_id: int = wulpus_id + self._config: Union[WulpusConfig, None] = None + self._status: Status = Status.NOT_CONNECTED + self._interface_usb_dongle = WulpusDongleUsb() + self._interface_direct = WulpusDongleDirect( + disconnected_callback=self._disconnected_callback + ) + self._last_connection: str = '' + self._latest_frame: Union[Measurement, None] = None + self._data: Union[np.ndarray, None] = None + self._data_acq_num: Union[np.ndarray, None] = None + self._data_tx_rx_id: Union[np.ndarray, None] = None + self._data_time: Union[np.ndarray, None] = None + # Event to signal new measurement data for WebSocket clients + self._new_measurement = asyncio.Event() + self._recording_start = time.time() + self._live_data_cnt = 0 + self._acquisition_running = False + + def _get_current_interface(self) -> DongleInterface: + if (self._last_connection.startswith("COM") or self._last_connection.startswith("/dev/")): + return self._interface_usb_dongle + else: + return self._interface_direct + + def get_acquisition_running(self) -> bool: + return self._acquisition_running + + def get_config(self): + return self._config + + async def get_connection_options(self): + conn_1 = await self._interface_direct.get_available() + conn_2 = await self._interface_usb_dongle.get_available() + return list(conn_1) + list(conn_2) + + async def connect(self, device_name: str = ''): + if self._status == Status.READY: + return + if len(device_name) == 0: + if len(self._last_connection) > 0: + device_name = self._last_connection + else: + raise ValueError("No device name specified.") + + self._last_connection = device_name + self._status = Status.CONNECTING + + if await self._get_current_interface().connect(device_str=device_name): + self._status = Status.READY + return + self._status = Status.NOT_CONNECTED + + async def disconnect(self): + await self._get_current_interface().close() + self._status = Status.NOT_CONNECTED + + def get_status(self): + return {"status": self._status, + "bluetooth": self._get_current_interface().get_status(), + "endpoint": self._get_current_interface().get_connection_endpoint(), + "us_config": self._config.us_config if self._config else None, + "tx_rx_config": self._config.tx_rx_config if self._config else None, + "progress": self._live_data_cnt / self._config.us_config.num_acqs if self._config else 0, + } + + def set_config(self, config: WulpusConfig) -> bytes: + self._config = config + + async def start(self): + """ + Start executing the config. Config needs to be set before starting. + """ + if self._status == Status.RUNNING: + return + if not self._config: + raise ValueError("No configuration set.") + bytes_config = gen_conf_package(self._config) + + # Send a restart command (in case the system is already running) + # Note: Can be removed after live config-update is tested + await self._get_current_interface().send_config(gen_restart_package()) + await asyncio.sleep(2.5) + + if await self._get_current_interface().send_config(bytes_config): + self._status = Status.RUNNING + asyncio.create_task(self._measure()) + else: + self._status = Status.NOT_CONNECTED + + def stop(self): + """ + Stops measurement task by using a flag + """ + self._acquisition_running = False + + def set_new_measurement_event(self, event: asyncio.Event): + self._new_measurement = event + + async def _measure(self): + self._recording_start = time.time() + number_of_acq = self._config.us_config.num_acqs + num_samples = self._config.us_config.num_samples + self._data = np.zeros((num_samples, number_of_acq), dtype=' Union[Measurement, None]: + return self._latest_frame + + def _structure_measurement(self, _data: np.ndarray, _tx_rx_id: int, _time: int) -> Measurement: + tx_rx_config = self._config.tx_rx_config[_tx_rx_id] + return Measurement( + data=_data.tolist(), + time=int(_time), + tx=tx_rx_config.tx_channels if tx_rx_config.tx_channels else [], + rx=tx_rx_config.rx_channels if tx_rx_config.rx_channels else [] + ) + + def _disconnected_callback(self, *args, **kwargs): + print("Device disconnected unexpectedly.") + self._status = Status.NOT_CONNECTED + self._acquisition_running = False diff --git a/sw/wulpus/wulpus_api.py b/sw/wulpus/wulpus_api.py new file mode 100644 index 0000000..82cca4a --- /dev/null +++ b/sw/wulpus/wulpus_api.py @@ -0,0 +1,76 @@ +import numpy as np + +from wulpus.wulpus_api_helper import (as_byte, build_tx_rx_configs, + fill_package_to_min_len, us_to_ticks) +from wulpus.wulpus_config_models import (PGA_GAIN_REG, + PGA_GAIN_VALUE, USS_CAPT_OVER_SAMPLE_RATES_REG, + USS_CAPTURE_ACQ_RATES_VALUE, WulpusConfig) + +START_BYTE_CONF_PACK = 250 +START_BYTE_RESTART = 251 + +DATA_FILE_EXTENSION = '.zip' +CONFIG_FILE_EXTENSION = '.json' + +def gen_restart_package(): + bytes_arr = np.array([START_BYTE_RESTART]).astype(' len(rx_only_ch): + temp_switch_config = np.bitwise_or.reduce(np.left_shift( + 1, RX_MAP[rx_tx_intersect_ch])) if rx_tx_intersect_ch else 0 + tx_cfgs[i] = np.bitwise_or( + tx_cfgs[i], temp_switch_config) + elif len(rx_only_ch) > 0: + temp_switch_config = np.bitwise_or.reduce( + np.left_shift(1, RX_MAP[rx_only_ch])) + tx_cfgs[i] = np.bitwise_or( + tx_cfgs[i], temp_switch_config) + i += 1 + return tx_cfgs[:i], rx_cfgs[:i] diff --git a/sw/wulpus/wulpus_config_models.py b/sw/wulpus/wulpus_config_models.py new file mode 100644 index 0000000..cb7a126 --- /dev/null +++ b/sw/wulpus/wulpus_config_models.py @@ -0,0 +1,119 @@ +from __future__ import annotations +import numpy as np +from pydantic import BaseModel, Field, field_validator +from typing import Union, Literal, Tuple, get_args +from wulpus.wulpus_api_helper import TX_RX_MAX_NUM_OF_CONFIGS, us_to_ticks + + +# Available RX gain in dB +PGA_GAIN = Literal[-6.5, -5.5, -4.6, -4.1, -3.3, -2.3, -1.4, -0.8, + 0.1, 1.0, 1.9, 2.6, 3.5, 4.4, 5.2, 6.0, 6.8, 7.7, + 8.7, 9.0, 9.8, 10.7, 11.7, 12.2, 13, 13.9, 14.9, + 15.5, 16.3, 17.2, 18.2, 18.8, 19.6, 20.5, 21.5, + 22, 22.8, 23.6, 24.6, 25.0, 25.8, 26.7, 27.7, + 28.1, 28.9, 29.8, 30.8] +PGA_GAIN_VALUE: Tuple[PGA_GAIN, ...] = get_args(PGA_GAIN) + +# Oversampling rate register values to be sent to HW +USS_CAPT_OVER_SAMPLE_RATES_REG = (0, 1, 2, 3, 4) + +# Register PGA RX gain value to write to HW +PGA_GAIN_REG = tuple(np.arange(17, 64)) + +# Acquisition rates +# # USS_CAPTURE_OVER_SAMPLE_RATES = Literal[10, 20, 40, 80, 160] +# # USS_CAPTURE_OVER_SAMPLE_RATES_VALUE: Tuple[USS_CAPTURE_OVER_SAMPLE_RATES, ...] = get_args( +# # USS_CAPTURE_OVER_SAMPLE_RATES) +# # USS_CAPTURE_ACQ_RATES = [80e6/x for x in USS_CAPTURE_OVER_SAMPLE_RATES_VALUE] +USS_CAPTURE_ACQ_RATES = Literal[8000000, 4000000, 2000000, 1000000, 500000] +USS_CAPTURE_ACQ_RATES_VALUE: Tuple[USS_CAPTURE_ACQ_RATES, ...] = get_args( + USS_CAPTURE_ACQ_RATES) + + +class UsConfig(BaseModel): + # Number of acquisitions to perform. + num_acqs: int = Field(default=400, ge=0) + # DC-DC turn on time in microseconds. + dcdc_turnon: int = Field( + default=195300, ge=0, le=2000000) + # Measurement period in microseconds. + meas_period: int = Field(default=321965, ge=655, le=2000000) + # Transducer frequency in Hertz. + trans_freq: int = Field(default=225e4, ge=0, le=5000000) + # Pulse frequency in Hertz. + pulse_freq: int = Field(default=225e4, ge=0, le=5000000) + # Number of pulses to excite the transducer. + num_pulses: int = Field(default=2, ge=0, le=30) + # Sampling frequency in Hertz. + sampling_freq: USS_CAPTURE_ACQ_RATES = USS_CAPTURE_ACQ_RATES_VALUE[0] + # Number of samples to acquire. + num_samples: int = Field(default=400, ge=0, le=800) + # RX gain in dB. (must be one of PGA_GAIN) + rx_gain: PGA_GAIN = PGA_GAIN_VALUE[-10] + # Number of TX/RX configurations. + num_txrx_configs: int = Field(default=1, ge=0, le=16) + # TX configurations. (Generated by WulpusRxTxConfigGen) + tx_configs: list[int] = Field(default_factory=lambda: [0], max_length=16) + # RX configurations. (Generated by WulpusRxTxConfigGen) + rx_configs: list[int] = Field(default_factory=lambda: [0], max_length=16) + # HV-MUX RX start time in microseconds. + start_hvmuxrx: int = Field( + default=500, ge=0, le=int(65535/us_to_ticks['start_hvmuxrx'])) + # PPG start time in microseconds. + start_ppg: int = Field(default=500, ge=0, le=int(65535 / + us_to_ticks['start_ppg'])) + # ADC turn on time in microseconds. + turnon_adc: int = Field(default=5, ge=0, le=int(65535 / + us_to_ticks['turnon_adc'])) + # PGA in bias start time in microseconds. + start_pgainbias: int = Field(default=5, ge=0, le=int(65535 / + us_to_ticks['start_pgainbias'])) + # ADC sampling start time in microseconds. + start_adcsampl: int = Field(default=503, ge=0, le=int(65535 / + us_to_ticks['start_adcsampl'])) + # Capture restart time in microseconds. + restart_capt: int = Field(default=3000, ge=0, le=int(65535 / + us_to_ticks['restart_capt'])) + # Capture timeout time in microseconds. + capt_timeout: int = Field(default=3000, ge=0, le=int(65535 / + us_to_ticks['capt_timeout'])) + + +MAX_CH_ID = 7 + + +class TxRxConfig(BaseModel): + config_id: int = Field(default=0, ge=0, lt=TX_RX_MAX_NUM_OF_CONFIGS) + tx_channels: list[int] = Field( + default_factory=list, max_length=MAX_CH_ID+1) + rx_channels: list[int] = Field( + default_factory=list, max_length=MAX_CH_ID+1) + optimized_switching: bool = True + + @field_validator('tx_channels', 'rx_channels', mode='after') + @classmethod + def validate_channel_ids(cls, channels): + for ch in channels: + if not (0 <= ch <= MAX_CH_ID): + raise ValueError( + f"Channel ID {ch} must be between 0 and {MAX_CH_ID}") + return channels + + +class MultiWulpusConfigEntrance(BaseModel): + wulpusId: int + config: WulpusConfig + + +class MultiWulpusConfig(BaseModel): + configs: list[MultiWulpusConfigEntrance] = Field(min_length=1) + +class WulpusConfig(BaseModel): + tx_rx_config: list[TxRxConfig] = Field( + min_length=1, max_length=TX_RX_MAX_NUM_OF_CONFIGS) + us_config: UsConfig + + +class ConDev(BaseModel): + con_dev: str + diff --git a/sw/wulpus/wulpus_mock.py b/sw/wulpus/wulpus_mock.py new file mode 100644 index 0000000..6b76fd0 --- /dev/null +++ b/sw/wulpus/wulpus_mock.py @@ -0,0 +1,70 @@ +import asyncio +import os +from typing import Union + +import numpy as np +from wulpus.interface_mock import WulpusDongleMock +from wulpus.helper import zip_to_dataframe + +from wulpus.wulpus import Status, Wulpus + + +class WulpusMock(Wulpus): + def __init__(self, wulpus_id=0): + super().__init__(wulpus_id=wulpus_id) + self._interface_usb_dongle = WulpusDongleMock() + self._interface_direct = WulpusDongleMock() + self._status = Status.READY + self._replay_file = None + + def get_status(self): + status = super().get_status() + status["mock"] = True + return status + + def set_replay_file(self, file_path: Union[str, None]): + if file_path is None: + self._replay_file = None + return + elif not os.path.isfile(file_path): + raise ValueError(f"File {file_path} does not exist.") + print(f"Replaying file set to {file_path}") + self._replay_file = file_path + + async def _measure(self): + if self._replay_file is None: + # Simulate reading random data from mocked dongle + await super()._measure() + else: + # Replay from new zip format + self._status = Status.RUNNING + self._acquisition_running = True + + df, config = zip_to_dataframe(self._replay_file) + + self._config = config + + self._data = np.column_stack([ + np.asarray(m, dtype=np.int16) for m in df['measurement'] + ]) + # Cast arrays to expected dtypes + self._data_acq_num = df['aq_number'].to_numpy(dtype='