shuishen
5 days ago f67d43a43f6c3e58922e3c2f457dd9caafebfc67
feat:具体能力实现处理
8 files modified
38 files added
208608 ■■■■■ changed files
.gitignore 3 ●●●● patch | view | raw | blame | history
AGENTS.md 45 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md 145 ●●●●● patch | view | raw | blame | history
README.md 44 ●●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 48 ●●●●● patch | view | raw | blame | history
apps/workbench-console/index.html 13 ●●●●● patch | view | raw | blame | history
apps/workbench-console/package-lock.json 2499 ●●●●● patch | view | raw | blame | history
apps/workbench-console/package.json 26 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/App.vue 60 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/api/artifacts.ts 101 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/ArtifactState.vue 10 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/ObjectDetectionPanel.vue 37 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/PageHeader.vue 10 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue 28 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/TrajectoryMap.vue 88 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/data/capabilities.ts 34 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/main.ts 11 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/router/index.ts 13 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/stores/artifacts.ts 61 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/stores/index.ts 3 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/styles.css 17 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/views/CapabilityView.vue 22 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/views/OverviewView.vue 26 ●●●●● patch | view | raw | blame | history
apps/workbench-console/tsconfig.app.json 21 ●●●●● patch | view | raw | blame | history
apps/workbench-console/tsconfig.json 7 ●●●●● patch | view | raw | blame | history
apps/workbench-console/tsconfig.node.json 12 ●●●●● patch | view | raw | blame | history
apps/workbench-console/vite.config.ts 39 ●●●●● patch | view | raw | blame | history
baseData/no-fly-zone-Chn_xS2X.geojson 203348 ●●●●● patch | view | raw | blame | history
baseData/uom-fly-zone.bin-B8O-S8Cs.gzip patch | view | raw | blame | history
baseData/演示.xlsx patch | view | raw | blame | history
baseData/田墩-疫木识别(勿删).kmz patch | view | raw | blame | history
capabilities/01-object-detection/README.md 103 ●●●●● patch | view | raw | blame | history
capabilities/01-object-detection/requirements.txt 6 ●●●● patch | view | raw | blame | history
capabilities/01-object-detection/run_detection.py 256 ●●●●● patch | view | raw | blame | history
capabilities/01-object-detection/run_geoai_vehicle_detection.py 220 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/README.md 122 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/generate_demo_inputs.py 197 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/requirements.txt 3 ●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/run_trajectory_analysis.py 636 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/tests/test_demo.py 73 ●●●●● patch | view | raw | blame | history
capabilities/README.md 20 ●●●●● patch | view | raw | blame | history
scripts/check-environment.ps1 11 ●●●●● patch | view | raw | blame | history
scripts/serve_workbench_console.py 91 ●●●●● patch | view | raw | blame | history
tests/test_serve_workbench_console.py 46 ●●●●● patch | view | raw | blame | history
新对话启动说明.txt 24 ●●●●● patch | view | raw | blame | history
能力边界与来源说明.md 29 ●●●●● patch | view | raw | blame | history
.gitignore
@@ -14,6 +14,8 @@
dist/
build/
*.egg-info/
*.pt
Ultralytics/
shared/data/raw/*
shared/data/interim/*
@@ -22,4 +24,3 @@
shared/outputs/*
!**/.gitkeep
!shared/**/README.md
AGENTS.md
New file
@@ -0,0 +1,45 @@
# GeoAI Workbench Instructions
## Scope
These instructions apply to the entire repository at `E:\AllWorkProject\geoai-workbench`.
Read `PROJECT_CONTEXT.md` before planning or changing a capability.
## Project Definition
- In this repository, GeoAI means workflows based on `opengeos/geoai` (`geoai-py`), not every spatial-intelligence feature.
- Keep the capability boundary explicit:
  - A: directly provided or documented by `geoai-py`.
  - B: built with `geoai-py` plus geospatial or AI ecosystem libraries.
  - C: a product/service capability that consumes GeoAI outputs.
- Verify current APIs against the installed package, source, or official documentation. Do not label B or C work as a built-in GeoAI function.
- Treat the user as a beginner who supplies goals, sample data, and visual feedback. Explain decisions in clear Chinese and handle implementation, environment work, debugging, and verification end to end.
## Repository Conventions
- Keep one capability in `capabilities/<number>-<name>/`.
- Every capability must have a `README.md` and `requirements.txt`; add code, tests, configs, and examples only when needed.
- Share compatible dependencies through `requirements/base.txt`. Use `.venvs/<capability>` for isolated environments when needed.
- Use Python 3.12 for GeoAI and deep-learning capabilities. Do not install project dependencies into system Python.
- Store reusable code in `src/geoai_common/` only after it is genuinely shared.
- Keep raw data, processed data, weights, caches, and generated outputs out of Git and under the existing `shared/` layout.
## Capability Workflow
1. Read the root documentation, `PROJECT_CONTEXT.md`, and the target capability README.
2. Classify the capability as A, B, or C and identify the exact role of `geoai-py`.
3. Define a runnable Demo with real input, inspectable output, structured metadata, and measurable acceptance criteria.
4. Inspect hardware and existing environments before installing dependencies. Default to CPU because no NVIDIA CUDA is available.
5. Validate one normal and one difficult representative sample before running a full directory.
6. Visually inspect generated images, maps, documents, or spreadsheets.
7. Record model, package, dataset, and weight licenses independently before suggesting commercial use.
8. Update the capability README and `PROJECT_CONTEXT.md` after verified changes.
## Engineering Rules
- Prefer existing project patterns and `geoai-py` APIs over new abstractions.
- Preserve geospatial coordinates only when the input has a valid CRS and transform. Ordinary JPEG results remain pixel coordinates.
- Report false positives, misses, runtime, and unsupported classes honestly. Do not hide limitations by lowering thresholds.
- Do not batch-run large datasets until a representative validation indicates that the approach is suitable.
- Do not delete or overwrite source imagery, model weights, results, or unrelated user changes.
- Do not commit unless the user explicitly asks.
PROJECT_CONTEXT.md
New file
@@ -0,0 +1,145 @@
# GeoAI Workbench Current Context
Last updated: 2026-08-14
This file is the current project snapshot for new Codex tasks. Keep it concise and replace stale facts instead of appending a conversation diary.
## Project Identity
- Repository: `E:\AllWorkProject\geoai-workbench`
- Technical foundation: [opengeos/geoai](https://github.com/opengeos/geoai), PyPI package `geoai-py`, import name `geoai`.
- Purpose: build independently runnable GeoAI capability Demos for drone and remote-sensing workflows, then evaluate integration with existing products.
- Product repositories in scope for later integration:
  - `E:\AllWorkProject\drone_project\ztzf-drone-web\applications\command-center-dashboard`
  - `E:\AllWorkProject\drone_project\ztzf-drone-web\applications\drone-web-manage`
- Capability classification: A = direct `geoai-py`; B = GeoAI plus ecosystem libraries; C = product/service capability consuming GeoAI results.
## Collaboration and Constraints
- The user is a beginner and can invest about 7-8 hours per day in running Demos and giving visual feedback.
- Codex is expected to handle environment setup, code, model selection, debugging, verification, and iteration.
- The machine runs Windows and PowerShell with 64 GB RAM and an AMD RX 590 GME 8 GB GPU. There is no NVIDIA CUDA, so current Demos use CPU inference.
- Use Python 3.12 for capability environments. The system also has Python 3.13, which must not replace or contaminate project environments.
- `geoai-py` is MIT-licensed, but every dependency, dataset, and model weight needs a separate commercial-license check.
- Current JPEG samples have no usable georeferencing. Their detections use pixel coordinates; accurate GeoJSON requires a georeferenced GeoTIFF.
## Environment Snapshot
Dedicated object-detection environment:
`E:\AllWorkProject\geoai-workbench\.venvs\01-object-detection`
| Component | Version |
| --- | --- |
| Python | 3.12.10 |
| geoai-py | 0.42.0 |
| torch | 2.13.0+cpu |
| torchvision | 0.28.0+cpu |
| rasterio | 1.5.1 |
| geopandas | 1.1.4 |
| ultralytics | 8.4.118 |
`pip check` passes in this environment.
Dedicated trajectory-analysis environment:
`E:\AllWorkProject\geoai-workbench\.venvs\15-trajectory-analysis`
| Component | Version |
| --- | --- |
| Python | 3.12.10 |
| pandas | 2.3.3 |
| geopandas | 1.1.4 |
| shapely | 2.1.2 |
| pyproj | 3.7.2 |
| scikit-learn | 1.9.0 |
| matplotlib | 3.11.1 |
`geoai-py` is intentionally not installed in this environment because version 0.42.0 has no trajectory, tracking, or behavior-recognition API. The capability consumes timestamped track results that may originate from an upstream GeoAI detection/export workflow. `pip check` passes.
## Local Experiment Console
- Location: `apps/workbench-console/`
- Frontend: Vue 3 + Vite + Ant Design Vue + Pinia. Trajectory maps use Cesium `1.126.0` with a local grid base layer.
- Build: `Set-Location .\apps\workbench-console; npm install; npm run build`
- Start command: `py -3.12 .\scripts\serve_workbench_console.py`
- URL: `http://127.0.0.1:6173` (built console); Vite development is `http://127.0.0.1:6174/apps/workbench-console/`. Only `6xxx` ports are accepted.
- Scope: independent, read-only local UI for this workbench. It has no code, API, account, data-upload, or task-execution link to the two drone-product repositories.
- First release: overview plus live result pages for `01-object-detection` and `15-trajectory-analysis`; other capability pages show their registered status only.
- File exposure: the local server permits only console assets, `shared/outputs`, and source images required for the object-detection comparison. It does not expose the rest of the repository.
## Capability Status
| Capability | Status | Current conclusion |
| --- | --- | --- |
| `00-change-detection` | Existing product capability confirmed; local Demo not implemented | The current system's orthophoto change detection already uses GeoAI. |
| `01-object-detection` | In progress; runnable people and vehicle experiments | Tiled YOLO helps people; GeoAI NWPU substantially improves top-down vehicles; tree detection is not implemented. |
| `15-trajectory-analysis` | Runnable CPU Demo verified | C capability consuming timestamped tracks: spatial metrics, DBSCAN and explicit behavior rules; not a built-in `geoai-py` function. |
| `02` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. |
## Object Detection Snapshot
Target classes: people, vehicles, and trees.
Inputs:
- Directory: `shared/data/raw/01-object-detection`
- 20 files total: 19 ordinary JPEG images and one internal DJI MPO with a `.jpeg` extension.
- Most images are 3840 x 2160. The MPO is skipped safely.
Environment and scripts:
- General tiled detector: `capabilities/01-object-detection/run_detection.py`
- GeoAI aerial vehicle detector: `capabilities/01-object-detection/run_geoai_vehicle_detection.py`
- General model: Ultralytics `yolo11n.pt`, CPU, 1024-pixel tiles, 20% overlap, confidence 0.20.
- Aerial vehicle model: `giswqs/nwpu-vhr10-maskrcnn:best_model.pth`, called through `geoai-py`, 512-pixel windows, 128-pixel overlap, confidence 0.30.
Measured results:
- Whole-image YOLO baseline across 19 JPEGs: 13 detections, including 3 people and 10 cars; this misses many small targets.
- `DJI_20260713102047_0001_V_19.jpeg`: tiled YOLO found five person candidates, including duplicates and a false positive; the visible red vehicle was still missed by both YOLO and NWPU.
- `DJI_20260810092727_0001_V_10.jpeg`: general YOLO found only 2-3 cars. GeoAI NWPU produced 33 raw vehicle detections and 32 after containment deduplication, with about 90 seconds CPU inference.
- NWPU is much better for top-down aerial vehicles but still misses vehicles and can produce partial-box duplicates or other aerial-class false positives.
Current technical decisions:
- Do not batch-run the final pipeline across all images yet.
- Use separate model branches: tiled YOLO for people, aerial-specific detection for vehicles, and a future tree-crown detection or segmentation model for trees.
- Do not claim that the current COCO or NWPU model detects trees.
- Establish a fixed manually annotated validation set before tuning or training.
- Product use of Ultralytics and NWPU weights remains blocked on license review and accuracy evaluation.
## Trajectory Analysis Snapshot
Inputs and artifacts:
- Generator: `capabilities/15-trajectory-analysis/generate_demo_inputs.py`
- Analyzer: `capabilities/15-trajectory-analysis/run_trajectory_analysis.py`
- Input contract: WGS84 timestamped CSV observations plus GeoJSON reference routes and zones, connected by a `*.case.json` manifest.
- Generated representative inputs: `shared/data/raw/15-trajectory-analysis`
- Inspectable outputs: `shared/outputs/15-trajectory-analysis`
Measured validation:
- The normal synthetic case has 2 tracks and 0 events.
- The difficult synthetic case has 3 tracks, drops 1 duplicate timestamp, and finds exactly one stop, one route deviation, one restricted-zone event, and one gathering event.
- Directory processing handled 5 tracks and 78 cleaned observations in about 2.8 seconds on CPU.
- Missing required CSV columns fail cleanly with exit code 2. Both 1400 x 980 PNG results were visually inspected.
Current decisions and limitations:
- Keep the boundary explicit: the current Demo is C because it does not call `geoai-py`; using geospatial ecosystem libraries alone does not make it B. `geoai-py` is only a possible upstream source.
- This is rule-based behavior detection, not learned video action recognition, and it assumes track IDs already exist.
- Synthetic data verifies logic only. Do not claim real-world accuracy or batch-run operational data yet.
- Next decision: obtain one normal and one difficult anonymized real track set, manually label events, then measure false positives, misses, GPS sensitivity, and per-entity thresholds.
## Starting a New Capability
Open a new Codex task with the repository as the working directory and use:
```text
使用 $geoai-capability-builder,创建下一个能力:<能力名称>。
先读取 AGENTS.md 和 PROJECT_CONTEXT.md,再准备环境、Demo 输入输出和验收方式。
```
After completing verified work, update this snapshot and the capability README. Change `AGENTS.md` only when a long-lived rule changes.
README.md
@@ -1,12 +1,16 @@
# GeoAI Workbench
GeoAI 多能力实验与产品验证工作区。每个能力都有独立目录、依赖声明和
输入/输出约定;通用依赖、共享数据、模型权重和运行结果由根目录统一管理。
本工作区以 [opengeos/geoai](https://github.com/opengeos/geoai) 的 `geoai-py`
为 GeoAI 工作流基础,围绕无人机和遥感数据做实验与产品验证。这里的“GeoAI
能力”分为三层:`geoai-py` 直接提供的功能、基于它的底层地理/AI生态组合出的
能力、以及需要由现有产品后端和业务规则实现的产品能力。后两类不能表述为
`geoai-py` 内置功能。
## 目录
```text
geoai-workbench/
|-- apps/             # 本地实验控制台等独立界面
|-- capabilities/     # 一个能力一个目录
|-- requirements/     # 跨能力共享的 Python 依赖
|-- scripts/          # 环境初始化和检查脚本
@@ -20,6 +24,9 @@
## 环境策略
- `geoai-py` 的包名是 `geoai-py`,导入名通常是 `geoai`,不是一个叫 `geoai`
  的操作系统或独立运行时。它要求 Python 3.12 或更高版本;本工作区固定使用
  Python 3.12 以兼容 PyTorch 和地理库。
- 推荐 Python 3.11 或 3.12。当前电脑的 Python 3.13 可运行部分能力,但地理和
  深度学习包的兼容性可能不完整。
- 根目录 `.venv` 用于轻量能力和公共开发工具。
@@ -41,5 +48,36 @@
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-environment.ps1
```
优先从 `06-spatial-reasoning`、`01-object-detection`、
## 本地实验控制台
控制台是本工作区内部的只读展示界面,独立于现有无人机产品。它读取
`shared/outputs/` 中已有的实验结果,方便直接查看标注影像、轨迹图、事件和运行
元数据;不会上传数据、调用外部接口或启动能力脚本。
```powershell
py -3.12 .\scripts\serve_workbench_console.py
```
浏览器访问 <http://127.0.0.1:6173>。默认端口为 `6173`,仅监听本机;其他可用的
`6xxx` 端口可以通过 `--port` 指定。界面说明见
`apps/workbench-console/README.md`。
优先从 `01-object-detection`、`02-semantic-mapping`、
`07-risk-rule-engine` 和 `12-quality-control-refly` 开始。
## 能力边界
- 直接能力:目标检测、语义/实例分割、影像分类、变化检测、遥感数据下载与
  预处理、地理结果导出与可视化等,具体以 `geoai-py` 当前版本文档和源码为准。
- 组合能力:空间测量、质量检测、灾害统计等,需要 `geoai-py` 加 Rasterio、
  GeoPandas、Shapely、PyTorch 或其他专用库。
- 产品能力:空间规则、风险评分、航线规划、集群调度、工单闭环、GeoLLM、
  知识图谱和模型治理,不是 `geoai-py` 单独提供的功能,需要产品服务实现。
完整映射见 `capabilities/README.md` 和规划工作簿中的“能力映射”工作表。
## 新对话继续工作
新建 Codex 对话时,把工作目录选择为本项目根目录,然后使用个人 Skill
`$geoai-capability-builder`。它会先读取 `AGENTS.md` 和 `PROJECT_CONTEXT.md`,
无需复制此前的长对话。可直接参考根目录的 `新对话启动说明.txt`。
apps/workbench-console/README.md
New file
@@ -0,0 +1,48 @@
# GeoAI Workbench 本地实验控制台
这是一个完全独立于无人机产品的本地只读控制台。它只展示本工作区已有能力的
输入说明、运行元数据和结果工件,不上传数据、不调用外部 API,也不提供删除、
覆盖或启动算法的操作。
## 启动
首次使用先安装和构建前端:
```powershell
Set-Location .\apps\workbench-console
npm install
npm run build
Set-Location ..\..
```
再从仓库根目录运行:
```powershell
py -3.12 .\scripts\serve_workbench_console.py
```
然后在浏览器打开 <http://127.0.0.1:6173>。服务默认只监听本机回环地址;若端口
已占用,可以显式传入另一个 `6xxx` 端口:
```powershell
py -3.12 .\scripts\serve_workbench_console.py --port 6174
```
开发 Vue 页面时,保持结果服务在 `6173`,并在另一个 PowerShell 中运行:
```powershell
Set-Location .\apps\workbench-console
npm run dev
```
Vite 开发服务固定使用 `http://127.0.0.1:6174/apps/workbench-console/`,会通过本机代理读取 `6173` 的结果工件。
## 目录职责
- `apps/workbench-console/src/`:Vue 3 组件、路由、Pinia 状态与能力适配层。
- `apps/workbench-console/dist/`:Vite 构建产物(不提交 Git)。
- `scripts/serve_workbench_console.py`:只读本地文件服务;仅暴露构建后的控制台、已有输出和目标检测原图,不暴露仓库其余文件。
- `shared/outputs/`:能力原始输出;控制台不复制、不修改这些文件。
首版已接入 `01-object-detection` 和 `15-trajectory-analysis`。其他能力保留目录和
边界状态,待其首个 Demo 产出可检查工件后再接入。
apps/workbench-console/index.html
New file
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="color-scheme" content="light" />
    <title>GeoAI Workbench | 本地实验控制台</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
apps/workbench-console/package-lock.json
New file
@@ -0,0 +1,2499 @@
{
  "name": "geoai-workbench-console",
  "version": "0.1.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "geoai-workbench-console",
      "version": "0.1.0",
      "dependencies": {
        "@ant-design/icons-vue": "^7.0.1",
        "ant-design-vue": "^4.2.6",
        "cesium": "1.126.0",
        "pinia": "^3.0.1",
        "vue": "^3.5.13",
        "vue-router": "^4.5.0"
      },
      "devDependencies": {
        "@vitejs/plugin-vue": "^5.2.1",
        "typescript": "^5.7.2",
        "vite": "^6.0.5",
        "vite-plugin-static-copy": "^2.3.0",
        "vue-tsc": "^2.2.0"
      }
    },
    "node_modules/@ant-design/colors": {
      "version": "6.0.0",
      "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-6.0.0.tgz",
      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
      "dependencies": {
        "@ctrl/tinycolor": "^3.4.0"
      }
    },
    "node_modules/@ant-design/icons-svg": {
      "version": "4.5.0",
      "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
      "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA=="
    },
    "node_modules/@ant-design/icons-vue": {
      "version": "7.0.1",
      "resolved": "https://registry.npmmirror.com/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
      "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
      "dependencies": {
        "@ant-design/colors": "^6.0.0",
        "@ant-design/icons-svg": "^4.2.1"
      },
      "peerDependencies": {
        "vue": ">=3.0.3"
      }
    },
    "node_modules/@babel/helper-string-parser": {
      "version": "7.29.7",
      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-identifier": {
      "version": "7.29.7",
      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/parser": {
      "version": "7.29.8",
      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz",
      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
      "dependencies": {
        "@babel/types": "^7.29.8"
      },
      "bin": {
        "parser": "bin/babel-parser.js"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@babel/runtime": {
      "version": "7.29.7",
      "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
      "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/types": {
      "version": "7.29.8",
      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
      "dependencies": {
        "@babel/helper-string-parser": "^7.29.7",
        "@babel/helper-validator-identifier": "^7.29.7"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@cesium/engine": {
      "version": "14.0.0",
      "resolved": "https://registry.npmmirror.com/@cesium/engine/-/engine-14.0.0.tgz",
      "integrity": "sha512-nmW0uQCyg4CRqi3a8o30gU9S9bFm3TLh2fRO74iv+6a8FFArvZ1xF7IqG2tyRjSXkyH4c5vpavNRT3R51R8NyQ==",
      "dependencies": {
        "@tweenjs/tween.js": "^25.0.0",
        "@zip.js/zip.js": "^2.7.34",
        "autolinker": "^4.0.0",
        "bitmap-sdf": "^1.0.3",
        "dompurify": "^3.0.2",
        "draco3d": "^1.5.1",
        "earcut": "^3.0.0",
        "grapheme-splitter": "^1.0.4",
        "jsep": "^1.3.8",
        "kdbush": "^4.0.1",
        "ktx-parse": "^0.7.0",
        "lerc": "^2.0.0",
        "mersenne-twister": "^1.1.0",
        "meshoptimizer": "^0.22.0",
        "pako": "^2.0.4",
        "protobufjs": "^7.1.0",
        "rbush": "3.0.1",
        "topojson-client": "^3.1.0",
        "urijs": "^1.19.7"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@cesium/widgets": {
      "version": "10.2.0",
      "resolved": "https://registry.npmmirror.com/@cesium/widgets/-/widgets-10.2.0.tgz",
      "integrity": "sha512-f0Wrp3MG02P2KSAopVESHWOhF+2eK9cQR9prEYGWuPM3iF6YktKrZqXzNnsOxCw1KPup0aSXsCrFl2asT5jF9g==",
      "dependencies": {
        "@cesium/engine": "^14.0.0",
        "nosleep.js": "^0.12.0"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@ctrl/tinycolor": {
      "version": "3.6.1",
      "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
      "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/@emotion/hash": {
      "version": "0.9.2",
      "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz",
      "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="
    },
    "node_modules/@emotion/unitless": {
      "version": "0.8.1",
      "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz",
      "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
    },
    "node_modules/@esbuild/aix-ppc64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "aix"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ia32": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-loong64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-mips64el": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
      "cpu": [
        "mips64el"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ppc64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-riscv64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-s390x": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openharmony-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "openharmony"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/sunos-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "sunos"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-arm64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-ia32": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-x64": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@jridgewell/sourcemap-codec": {
      "version": "1.5.5",
      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
    },
    "node_modules/@napi-rs/lzma-linux-x64-gnu": {
      "version": "1.5.1",
      "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
      "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^22.20 || ^24.12 || >=25"
      }
    },
    "node_modules/@nodelib/fs.scandir": {
      "version": "2.1.5",
      "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
      "dev": true,
      "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.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
      "dev": true,
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@nodelib/fs.walk": {
      "version": "1.2.8",
      "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
      "dev": true,
      "dependencies": {
        "@nodelib/fs.scandir": "2.1.5",
        "fastq": "^1.6.0"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@protobufjs/aspromise": {
      "version": "1.1.2",
      "resolved": "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
      "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
    },
    "node_modules/@protobufjs/base64": {
      "version": "1.1.2",
      "resolved": "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz",
      "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
    },
    "node_modules/@protobufjs/codegen": {
      "version": "2.0.5",
      "resolved": "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz",
      "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
    },
    "node_modules/@protobufjs/eventemitter": {
      "version": "1.1.1",
      "resolved": "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
      "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="
    },
    "node_modules/@protobufjs/fetch": {
      "version": "1.1.1",
      "resolved": "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.1.tgz",
      "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
      "dependencies": {
        "@protobufjs/aspromise": "^1.1.1"
      }
    },
    "node_modules/@protobufjs/float": {
      "version": "1.0.2",
      "resolved": "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz",
      "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
    },
    "node_modules/@protobufjs/path": {
      "version": "1.1.2",
      "resolved": "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz",
      "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
    },
    "node_modules/@protobufjs/pool": {
      "version": "1.1.0",
      "resolved": "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz",
      "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
    },
    "node_modules/@protobufjs/utf8": {
      "version": "1.1.2",
      "resolved": "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.2.tgz",
      "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="
    },
    "node_modules/@rollup/rollup-android-arm-eabi": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
      "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-android-arm64": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
      "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-darwin-arm64": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
      "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-darwin-x64": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
      "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-arm64": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
      "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-x64": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
      "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
      "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
      "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-gnu": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
      "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-musl": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
      "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-gnu": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
      "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-musl": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
      "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
      "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-musl": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
      "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
      "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-musl": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
      "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-s390x-gnu": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
      "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-gnu": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
      "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-musl": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
      "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-openbsd-x64": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
      "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "openbsd"
      ]
    },
    "node_modules/@rollup/rollup-openharmony-arm64": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
      "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "openharmony"
      ]
    },
    "node_modules/@rollup/rollup-win32-arm64-msvc": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
      "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-ia32-msvc": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
      "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-gnu": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
      "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-msvc": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
      "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@simonwep/pickr": {
      "version": "1.8.2",
      "resolved": "https://registry.npmmirror.com/@simonwep/pickr/-/pickr-1.8.2.tgz",
      "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
      "dependencies": {
        "core-js": "^3.15.1",
        "nanopop": "^2.1.0"
      }
    },
    "node_modules/@tweenjs/tween.js": {
      "version": "25.0.0",
      "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
      "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="
    },
    "node_modules/@types/estree": {
      "version": "1.0.9",
      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
      "dev": true
    },
    "node_modules/@types/node": {
      "version": "26.2.0",
      "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.2.0.tgz",
      "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
      "dependencies": {
        "undici-types": "~8.3.0"
      }
    },
    "node_modules/@types/trusted-types": {
      "version": "2.0.7",
      "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
      "optional": true
    },
    "node_modules/@vitejs/plugin-vue": {
      "version": "5.2.4",
      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
      "dev": true,
      "engines": {
        "node": "^18.0.0 || >=20.0.0"
      },
      "peerDependencies": {
        "vite": "^5.0.0 || ^6.0.0",
        "vue": "^3.2.25"
      }
    },
    "node_modules/@volar/language-core": {
      "version": "2.4.15",
      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
      "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
      "dev": true,
      "dependencies": {
        "@volar/source-map": "2.4.15"
      }
    },
    "node_modules/@volar/source-map": {
      "version": "2.4.15",
      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz",
      "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
      "dev": true
    },
    "node_modules/@volar/typescript": {
      "version": "2.4.15",
      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz",
      "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
      "dev": true,
      "dependencies": {
        "@volar/language-core": "2.4.15",
        "path-browserify": "^1.0.1",
        "vscode-uri": "^3.0.8"
      }
    },
    "node_modules/@vue/compiler-core": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
      "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
      "dependencies": {
        "@babel/parser": "^7.29.8",
        "@vue/shared": "3.5.41",
        "entities": "^7.0.1",
        "estree-walker": "^2.0.2",
        "source-map-js": "^1.2.1"
      }
    },
    "node_modules/@vue/compiler-dom": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
      "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
      "dependencies": {
        "@vue/compiler-core": "3.5.41",
        "@vue/shared": "3.5.41"
      }
    },
    "node_modules/@vue/compiler-sfc": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz",
      "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==",
      "dependencies": {
        "@babel/parser": "^7.29.8",
        "@vue/compiler-core": "3.5.41",
        "@vue/compiler-dom": "3.5.41",
        "@vue/compiler-ssr": "3.5.41",
        "@vue/shared": "3.5.41",
        "estree-walker": "^2.0.2",
        "magic-string": "^0.30.21",
        "postcss": "^8.5.19",
        "source-map-js": "^1.2.1"
      }
    },
    "node_modules/@vue/compiler-ssr": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
      "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==",
      "dependencies": {
        "@vue/compiler-dom": "3.5.41",
        "@vue/shared": "3.5.41"
      }
    },
    "node_modules/@vue/compiler-vue2": {
      "version": "2.7.16",
      "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
      "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
      "dev": true,
      "dependencies": {
        "de-indent": "^1.0.2",
        "he": "^1.2.0"
      }
    },
    "node_modules/@vue/devtools-api": {
      "version": "7.7.10",
      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.10.tgz",
      "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==",
      "dependencies": {
        "@vue/devtools-kit": "^7.7.10"
      }
    },
    "node_modules/@vue/devtools-kit": {
      "version": "7.7.10",
      "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz",
      "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==",
      "dependencies": {
        "@vue/devtools-shared": "^7.7.10",
        "birpc": "^2.3.0",
        "hookable": "^5.5.3",
        "mitt": "^3.0.1",
        "perfect-debounce": "^1.0.0",
        "speakingurl": "^14.0.1",
        "superjson": "^2.2.2"
      }
    },
    "node_modules/@vue/devtools-shared": {
      "version": "7.7.10",
      "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz",
      "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==",
      "dependencies": {
        "rfdc": "^1.4.1"
      }
    },
    "node_modules/@vue/language-core": {
      "version": "2.2.12",
      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz",
      "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
      "dev": true,
      "dependencies": {
        "@volar/language-core": "2.4.15",
        "@vue/compiler-dom": "^3.5.0",
        "@vue/compiler-vue2": "^2.7.16",
        "@vue/shared": "^3.5.0",
        "alien-signals": "^1.0.3",
        "minimatch": "^9.0.3",
        "muggle-string": "^0.4.1",
        "path-browserify": "^1.0.1"
      },
      "peerDependencies": {
        "typescript": "*"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/@vue/reactivity": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.41.tgz",
      "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
      "dependencies": {
        "@vue/shared": "3.5.41"
      }
    },
    "node_modules/@vue/runtime-core": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.41.tgz",
      "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==",
      "dependencies": {
        "@vue/reactivity": "3.5.41",
        "@vue/shared": "3.5.41"
      }
    },
    "node_modules/@vue/runtime-dom": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz",
      "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==",
      "dependencies": {
        "@vue/reactivity": "3.5.41",
        "@vue/runtime-core": "3.5.41",
        "@vue/shared": "3.5.41",
        "csstype": "^3.2.3"
      }
    },
    "node_modules/@vue/server-renderer": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.41.tgz",
      "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==",
      "dependencies": {
        "@vue/compiler-ssr": "3.5.41",
        "@vue/runtime-dom": "3.5.41",
        "@vue/shared": "3.5.41"
      }
    },
    "node_modules/@vue/shared": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.41.tgz",
      "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="
    },
    "node_modules/@zip.js/zip.js": {
      "version": "2.8.49",
      "resolved": "https://registry.npmmirror.com/@zip.js/zip.js/-/zip.js-2.8.49.tgz",
      "integrity": "sha512-TY3fKR/IQqPJqrOQAohvW6kv7Qd1aehzU7M7hbqhNNKxVP8uKSMO+aUlBwtdWzTCd62E0YOB+HHW9N0hMihrWw==",
      "engines": {
        "bun": ">=0.7.0",
        "deno": ">=1.0.0",
        "node": ">=18.0.0"
      }
    },
    "node_modules/alien-signals": {
      "version": "1.0.13",
      "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
      "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
      "dev": true
    },
    "node_modules/ant-design-vue": {
      "version": "4.2.6",
      "resolved": "https://registry.npmmirror.com/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
      "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
      "dependencies": {
        "@ant-design/colors": "^6.0.0",
        "@ant-design/icons-vue": "^7.0.0",
        "@babel/runtime": "^7.10.5",
        "@ctrl/tinycolor": "^3.5.0",
        "@emotion/hash": "^0.9.0",
        "@emotion/unitless": "^0.8.0",
        "@simonwep/pickr": "~1.8.0",
        "array-tree-filter": "^2.1.0",
        "async-validator": "^4.0.0",
        "csstype": "^3.1.1",
        "dayjs": "^1.10.5",
        "dom-align": "^1.12.1",
        "dom-scroll-into-view": "^2.0.0",
        "lodash": "^4.17.21",
        "lodash-es": "^4.17.15",
        "resize-observer-polyfill": "^1.5.1",
        "scroll-into-view-if-needed": "^2.2.25",
        "shallow-equal": "^1.0.0",
        "stylis": "^4.1.3",
        "throttle-debounce": "^5.0.0",
        "vue-types": "^3.0.0",
        "warning": "^4.0.0"
      },
      "engines": {
        "node": ">=12.22.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/ant-design-vue"
      },
      "peerDependencies": {
        "vue": ">=3.2.0"
      }
    },
    "node_modules/anymatch": {
      "version": "3.1.3",
      "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
      "dev": true,
      "dependencies": {
        "normalize-path": "^3.0.0",
        "picomatch": "^2.0.4"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/anymatch/node_modules/picomatch": {
      "version": "2.3.2",
      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
      "dev": true,
      "engines": {
        "node": ">=8.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/array-tree-filter": {
      "version": "2.1.0",
      "resolved": "https://registry.npmmirror.com/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
      "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw=="
    },
    "node_modules/async-validator": {
      "version": "4.2.5",
      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="
    },
    "node_modules/autolinker": {
      "version": "4.1.5",
      "resolved": "https://registry.npmmirror.com/autolinker/-/autolinker-4.1.5.tgz",
      "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==",
      "dependencies": {
        "tslib": "^2.8.1"
      },
      "engines": {
        "pnpm": ">=10.10.0"
      }
    },
    "node_modules/balanced-match": {
      "version": "1.0.2",
      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
      "dev": true
    },
    "node_modules/binary-extensions": {
      "version": "2.3.0",
      "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
      "dev": true,
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/birpc": {
      "version": "2.9.0",
      "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
      "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
      "funding": {
        "url": "https://github.com/sponsors/antfu"
      }
    },
    "node_modules/bitmap-sdf": {
      "version": "1.0.4",
      "resolved": "https://registry.npmmirror.com/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz",
      "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg=="
    },
    "node_modules/brace-expansion": {
      "version": "2.1.4",
      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz",
      "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
      "dev": true,
      "dependencies": {
        "balanced-match": "^1.0.0"
      }
    },
    "node_modules/braces": {
      "version": "3.0.3",
      "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
      "dev": true,
      "dependencies": {
        "fill-range": "^7.1.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/cesium": {
      "version": "1.126.0",
      "resolved": "https://registry.npmmirror.com/cesium/-/cesium-1.126.0.tgz",
      "integrity": "sha512-29Cy6eq9NzTbQUaOXQEnVJyYoHCeizrBTbbB76OClI5+FNq9saUUx+iQXg/J6r8vX+KWt6oD/lVpZ0QMcvAgog==",
      "workspaces": [
        "packages/engine",
        "packages/widgets"
      ],
      "dependencies": {
        "@cesium/engine": "^14.0.0",
        "@cesium/widgets": "^10.2.0"
      },
      "engines": {
        "node": ">=18.18.0"
      }
    },
    "node_modules/chokidar": {
      "version": "3.6.0",
      "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
      "dev": true,
      "dependencies": {
        "anymatch": "~3.1.2",
        "braces": "~3.0.2",
        "glob-parent": "~5.1.2",
        "is-binary-path": "~2.1.0",
        "is-glob": "~4.0.1",
        "normalize-path": "~3.0.0",
        "readdirp": "~3.6.0"
      },
      "engines": {
        "node": ">= 8.10.0"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      },
      "optionalDependencies": {
        "fsevents": "~2.3.2"
      }
    },
    "node_modules/commander": {
      "version": "2.20.3",
      "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz",
      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
    },
    "node_modules/compute-scroll-into-view": {
      "version": "1.0.20",
      "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
    },
    "node_modules/copy-anything": {
      "version": "4.0.5",
      "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz",
      "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
      "dependencies": {
        "is-what": "^5.2.0"
      },
      "engines": {
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/mesqueeb"
      }
    },
    "node_modules/core-js": {
      "version": "3.50.0",
      "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.50.0.tgz",
      "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
      "hasInstallScript": true,
      "engines": {
        "node": "*"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/core-js"
      }
    },
    "node_modules/csstype": {
      "version": "3.2.3",
      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
    },
    "node_modules/dayjs": {
      "version": "1.11.21",
      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
      "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="
    },
    "node_modules/de-indent": {
      "version": "1.0.2",
      "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
      "dev": true
    },
    "node_modules/dom-align": {
      "version": "1.12.4",
      "resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
      "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="
    },
    "node_modules/dom-scroll-into-view": {
      "version": "2.0.1",
      "resolved": "https://registry.npmmirror.com/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
      "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w=="
    },
    "node_modules/dompurify": {
      "version": "3.4.13",
      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz",
      "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
      "optionalDependencies": {
        "@types/trusted-types": "^2.0.7"
      }
    },
    "node_modules/draco3d": {
      "version": "1.5.7",
      "resolved": "https://registry.npmmirror.com/draco3d/-/draco3d-1.5.7.tgz",
      "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="
    },
    "node_modules/earcut": {
      "version": "3.2.3",
      "resolved": "https://registry.npmmirror.com/earcut/-/earcut-3.2.3.tgz",
      "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="
    },
    "node_modules/entities": {
      "version": "7.0.1",
      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
      "engines": {
        "node": ">=0.12"
      },
      "funding": {
        "url": "https://github.com/fb55/entities?sponsor=1"
      }
    },
    "node_modules/esbuild": {
      "version": "0.25.12",
      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
      "dev": true,
      "hasInstallScript": true,
      "bin": {
        "esbuild": "bin/esbuild"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "@esbuild/aix-ppc64": "0.25.12",
        "@esbuild/android-arm": "0.25.12",
        "@esbuild/android-arm64": "0.25.12",
        "@esbuild/android-x64": "0.25.12",
        "@esbuild/darwin-arm64": "0.25.12",
        "@esbuild/darwin-x64": "0.25.12",
        "@esbuild/freebsd-arm64": "0.25.12",
        "@esbuild/freebsd-x64": "0.25.12",
        "@esbuild/linux-arm": "0.25.12",
        "@esbuild/linux-arm64": "0.25.12",
        "@esbuild/linux-ia32": "0.25.12",
        "@esbuild/linux-loong64": "0.25.12",
        "@esbuild/linux-mips64el": "0.25.12",
        "@esbuild/linux-ppc64": "0.25.12",
        "@esbuild/linux-riscv64": "0.25.12",
        "@esbuild/linux-s390x": "0.25.12",
        "@esbuild/linux-x64": "0.25.12",
        "@esbuild/netbsd-arm64": "0.25.12",
        "@esbuild/netbsd-x64": "0.25.12",
        "@esbuild/openbsd-arm64": "0.25.12",
        "@esbuild/openbsd-x64": "0.25.12",
        "@esbuild/openharmony-arm64": "0.25.12",
        "@esbuild/sunos-x64": "0.25.12",
        "@esbuild/win32-arm64": "0.25.12",
        "@esbuild/win32-ia32": "0.25.12",
        "@esbuild/win32-x64": "0.25.12"
      }
    },
    "node_modules/estree-walker": {
      "version": "2.0.2",
      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
    },
    "node_modules/fast-glob": {
      "version": "3.3.3",
      "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
      "dev": true,
      "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/fastq": {
      "version": "1.20.1",
      "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
      "dev": true,
      "dependencies": {
        "reusify": "^1.0.4"
      }
    },
    "node_modules/fdir": {
      "version": "6.5.0",
      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
      "dev": true,
      "engines": {
        "node": ">=12.0.0"
      },
      "peerDependencies": {
        "picomatch": "^3 || ^4"
      },
      "peerDependenciesMeta": {
        "picomatch": {
          "optional": true
        }
      }
    },
    "node_modules/fill-range": {
      "version": "7.1.1",
      "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
      "dev": true,
      "dependencies": {
        "to-regex-range": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/fs-extra": {
      "version": "11.4.0",
      "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.4.0.tgz",
      "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
      "dev": true,
      "dependencies": {
        "graceful-fs": "^4.2.0",
        "jsonfile": "^6.0.1",
        "universalify": "^2.0.0"
      },
      "engines": {
        "node": ">=14.14"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
      "dev": true,
      "hasInstallScript": true,
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/glob-parent": {
      "version": "5.1.2",
      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
      "dev": true,
      "dependencies": {
        "is-glob": "^4.0.1"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/graceful-fs": {
      "version": "4.2.11",
      "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
      "dev": true
    },
    "node_modules/grapheme-splitter": {
      "version": "1.0.4",
      "resolved": "https://registry.npmmirror.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
      "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
    },
    "node_modules/he": {
      "version": "1.2.0",
      "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
      "dev": true,
      "bin": {
        "he": "bin/he"
      }
    },
    "node_modules/hookable": {
      "version": "5.5.3",
      "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
      "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
    },
    "node_modules/is-binary-path": {
      "version": "2.1.0",
      "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
      "dev": true,
      "dependencies": {
        "binary-extensions": "^2.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/is-extglob": {
      "version": "2.1.1",
      "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
      "dev": true,
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-glob": {
      "version": "4.0.3",
      "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
      "dev": true,
      "dependencies": {
        "is-extglob": "^2.1.1"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-number": {
      "version": "7.0.0",
      "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
      "dev": true,
      "engines": {
        "node": ">=0.12.0"
      }
    },
    "node_modules/is-plain-object": {
      "version": "3.0.1",
      "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-3.0.1.tgz",
      "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-what": {
      "version": "5.5.0",
      "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
      "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
      "engines": {
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/mesqueeb"
      }
    },
    "node_modules/js-tokens": {
      "version": "4.0.0",
      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
    },
    "node_modules/jsep": {
      "version": "1.4.0",
      "resolved": "https://registry.npmmirror.com/jsep/-/jsep-1.4.0.tgz",
      "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
      "engines": {
        "node": ">= 10.16.0"
      }
    },
    "node_modules/jsonfile": {
      "version": "6.2.1",
      "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz",
      "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
      "dev": true,
      "dependencies": {
        "universalify": "^2.0.0"
      },
      "optionalDependencies": {
        "graceful-fs": "^4.1.6"
      }
    },
    "node_modules/kdbush": {
      "version": "4.1.0",
      "resolved": "https://registry.npmmirror.com/kdbush/-/kdbush-4.1.0.tgz",
      "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="
    },
    "node_modules/ktx-parse": {
      "version": "0.7.1",
      "resolved": "https://registry.npmmirror.com/ktx-parse/-/ktx-parse-0.7.1.tgz",
      "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ=="
    },
    "node_modules/lerc": {
      "version": "2.0.0",
      "resolved": "https://registry.npmmirror.com/lerc/-/lerc-2.0.0.tgz",
      "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg=="
    },
    "node_modules/lodash": {
      "version": "4.18.1",
      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
    },
    "node_modules/lodash-es": {
      "version": "4.18.1",
      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="
    },
    "node_modules/long": {
      "version": "5.3.2",
      "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
      "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
    },
    "node_modules/loose-envify": {
      "version": "1.4.0",
      "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
      "dependencies": {
        "js-tokens": "^3.0.0 || ^4.0.0"
      },
      "bin": {
        "loose-envify": "cli.js"
      }
    },
    "node_modules/magic-string": {
      "version": "0.30.21",
      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
      "dependencies": {
        "@jridgewell/sourcemap-codec": "^1.5.5"
      }
    },
    "node_modules/merge2": {
      "version": "1.4.1",
      "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
      "dev": true,
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/mersenne-twister": {
      "version": "1.1.0",
      "resolved": "https://registry.npmmirror.com/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
      "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA=="
    },
    "node_modules/meshoptimizer": {
      "version": "0.22.0",
      "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
      "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="
    },
    "node_modules/micromatch": {
      "version": "4.0.8",
      "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
      "dev": true,
      "dependencies": {
        "braces": "^3.0.3",
        "picomatch": "^2.3.1"
      },
      "engines": {
        "node": ">=8.6"
      }
    },
    "node_modules/micromatch/node_modules/picomatch": {
      "version": "2.3.2",
      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
      "dev": true,
      "engines": {
        "node": ">=8.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/minimatch": {
      "version": "9.0.9",
      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
      "dev": true,
      "dependencies": {
        "brace-expansion": "^2.0.2"
      },
      "engines": {
        "node": ">=16 || 14 >=14.17"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/mitt": {
      "version": "3.0.1",
      "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
    },
    "node_modules/muggle-string": {
      "version": "0.4.1",
      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
      "dev": true
    },
    "node_modules/nanoid": {
      "version": "3.3.18",
      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "bin": {
        "nanoid": "bin/nanoid.cjs"
      },
      "engines": {
        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
      }
    },
    "node_modules/nanopop": {
      "version": "2.4.2",
      "resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.4.2.tgz",
      "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw=="
    },
    "node_modules/normalize-path": {
      "version": "3.0.0",
      "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
      "dev": true,
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/nosleep.js": {
      "version": "0.12.0",
      "resolved": "https://registry.npmmirror.com/nosleep.js/-/nosleep.js-0.12.0.tgz",
      "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="
    },
    "node_modules/p-map": {
      "version": "7.0.6",
      "resolved": "https://registry.npmmirror.com/p-map/-/p-map-7.0.6.tgz",
      "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==",
      "dev": true,
      "engines": {
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/pako": {
      "version": "2.2.0",
      "resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
      "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/puzrin"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/nodeca"
        }
      ]
    },
    "node_modules/path-browserify": {
      "version": "1.0.1",
      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
      "dev": true
    },
    "node_modules/perfect-debounce": {
      "version": "1.0.0",
      "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
    },
    "node_modules/picomatch": {
      "version": "4.0.5",
      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
      "dev": true,
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/pinia": {
      "version": "3.0.4",
      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz",
      "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
      "dependencies": {
        "@vue/devtools-api": "^7.7.7"
      },
      "funding": {
        "url": "https://github.com/sponsors/posva"
      },
      "peerDependencies": {
        "typescript": ">=4.5.0",
        "vue": "^3.5.11"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/postcss": {
      "version": "8.5.26",
      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
      "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"
        }
      ],
      "dependencies": {
        "nanoid": "^3.3.17",
        "picocolors": "^1.1.1",
        "source-map-js": "^1.2.1"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/protobufjs": {
      "version": "7.6.5",
      "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.6.5.tgz",
      "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
      "hasInstallScript": true,
      "dependencies": {
        "@protobufjs/aspromise": "^1.1.2",
        "@protobufjs/base64": "^1.1.2",
        "@protobufjs/codegen": "^2.0.5",
        "@protobufjs/eventemitter": "^1.1.1",
        "@protobufjs/fetch": "^1.1.1",
        "@protobufjs/float": "^1.0.2",
        "@protobufjs/path": "^1.1.2",
        "@protobufjs/pool": "^1.1.0",
        "@protobufjs/utf8": "^1.1.1",
        "@types/node": ">=13.7.0",
        "long": "^5.3.2"
      },
      "engines": {
        "node": ">=12.0.0"
      }
    },
    "node_modules/queue-microtask": {
      "version": "1.2.3",
      "resolved": "https://registry.npmmirror.com/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"
        }
      ]
    },
    "node_modules/quickselect": {
      "version": "2.0.0",
      "resolved": "https://registry.npmmirror.com/quickselect/-/quickselect-2.0.0.tgz",
      "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
    },
    "node_modules/rbush": {
      "version": "3.0.1",
      "resolved": "https://registry.npmmirror.com/rbush/-/rbush-3.0.1.tgz",
      "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
      "dependencies": {
        "quickselect": "^2.0.0"
      }
    },
    "node_modules/readdirp": {
      "version": "3.6.0",
      "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
      "dev": true,
      "dependencies": {
        "picomatch": "^2.2.1"
      },
      "engines": {
        "node": ">=8.10.0"
      }
    },
    "node_modules/readdirp/node_modules/picomatch": {
      "version": "2.3.2",
      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
      "dev": true,
      "engines": {
        "node": ">=8.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/resize-observer-polyfill": {
      "version": "1.5.1",
      "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
    },
    "node_modules/reusify": {
      "version": "1.1.0",
      "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
      "dev": true,
      "engines": {
        "iojs": ">=1.0.0",
        "node": ">=0.10.0"
      }
    },
    "node_modules/rfdc": {
      "version": "1.4.1",
      "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
    },
    "node_modules/rollup": {
      "version": "4.62.4",
      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz",
      "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
      "dev": true,
      "dependencies": {
        "@types/estree": "1.0.9"
      },
      "bin": {
        "rollup": "dist/bin/rollup"
      },
      "engines": {
        "node": ">=18.0.0",
        "npm": ">=8.0.0"
      },
      "optionalDependencies": {
        "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
        "@rollup/rollup-android-arm-eabi": "4.62.4",
        "@rollup/rollup-android-arm64": "4.62.4",
        "@rollup/rollup-darwin-arm64": "4.62.4",
        "@rollup/rollup-darwin-x64": "4.62.4",
        "@rollup/rollup-freebsd-arm64": "4.62.4",
        "@rollup/rollup-freebsd-x64": "4.62.4",
        "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
        "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
        "@rollup/rollup-linux-arm64-gnu": "4.62.4",
        "@rollup/rollup-linux-arm64-musl": "4.62.4",
        "@rollup/rollup-linux-loong64-gnu": "4.62.4",
        "@rollup/rollup-linux-loong64-musl": "4.62.4",
        "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
        "@rollup/rollup-linux-ppc64-musl": "4.62.4",
        "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
        "@rollup/rollup-linux-riscv64-musl": "4.62.4",
        "@rollup/rollup-linux-s390x-gnu": "4.62.4",
        "@rollup/rollup-linux-x64-gnu": "4.62.4",
        "@rollup/rollup-linux-x64-musl": "4.62.4",
        "@rollup/rollup-openbsd-x64": "4.62.4",
        "@rollup/rollup-openharmony-arm64": "4.62.4",
        "@rollup/rollup-win32-arm64-msvc": "4.62.4",
        "@rollup/rollup-win32-ia32-msvc": "4.62.4",
        "@rollup/rollup-win32-x64-gnu": "4.62.4",
        "@rollup/rollup-win32-x64-msvc": "4.62.4",
        "fsevents": "~2.3.2"
      }
    },
    "node_modules/run-parallel": {
      "version": "1.2.0",
      "resolved": "https://registry.npmmirror.com/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"
        }
      ],
      "dependencies": {
        "queue-microtask": "^1.2.2"
      }
    },
    "node_modules/scroll-into-view-if-needed": {
      "version": "2.2.31",
      "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
      "dependencies": {
        "compute-scroll-into-view": "^1.0.20"
      }
    },
    "node_modules/shallow-equal": {
      "version": "1.2.1",
      "resolved": "https://registry.npmmirror.com/shallow-equal/-/shallow-equal-1.2.1.tgz",
      "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
    },
    "node_modules/source-map-js": {
      "version": "1.2.1",
      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/speakingurl": {
      "version": "14.0.1",
      "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
      "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/stylis": {
      "version": "4.4.0",
      "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz",
      "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="
    },
    "node_modules/superjson": {
      "version": "2.2.6",
      "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz",
      "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
      "dependencies": {
        "copy-anything": "^4"
      },
      "engines": {
        "node": ">=16"
      }
    },
    "node_modules/throttle-debounce": {
      "version": "5.0.2",
      "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
      "engines": {
        "node": ">=12.22"
      }
    },
    "node_modules/tinyglobby": {
      "version": "0.2.17",
      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
      "dev": true,
      "dependencies": {
        "fdir": "^6.5.0",
        "picomatch": "^4.0.4"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "funding": {
        "url": "https://github.com/sponsors/SuperchupuDev"
      }
    },
    "node_modules/to-regex-range": {
      "version": "5.0.1",
      "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
      "dev": true,
      "dependencies": {
        "is-number": "^7.0.0"
      },
      "engines": {
        "node": ">=8.0"
      }
    },
    "node_modules/topojson-client": {
      "version": "3.1.0",
      "resolved": "https://registry.npmmirror.com/topojson-client/-/topojson-client-3.1.0.tgz",
      "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
      "dependencies": {
        "commander": "2"
      },
      "bin": {
        "topo2geo": "bin/topo2geo",
        "topomerge": "bin/topomerge",
        "topoquantize": "bin/topoquantize"
      }
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
      "devOptional": true,
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/undici-types": {
      "version": "8.3.0",
      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
      "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
    },
    "node_modules/universalify": {
      "version": "2.0.1",
      "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
      "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
      "dev": true,
      "engines": {
        "node": ">= 10.0.0"
      }
    },
    "node_modules/urijs": {
      "version": "1.19.11",
      "resolved": "https://registry.npmmirror.com/urijs/-/urijs-1.19.11.tgz",
      "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="
    },
    "node_modules/vite": {
      "version": "6.4.3",
      "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
      "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
      "dev": true,
      "dependencies": {
        "esbuild": "^0.25.0",
        "fdir": "^6.4.4",
        "picomatch": "^4.0.2",
        "postcss": "^8.5.3",
        "rollup": "^4.34.9",
        "tinyglobby": "^0.2.13"
      },
      "bin": {
        "vite": "bin/vite.js"
      },
      "engines": {
        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
      },
      "funding": {
        "url": "https://github.com/vitejs/vite?sponsor=1"
      },
      "optionalDependencies": {
        "fsevents": "~2.3.3"
      },
      "peerDependencies": {
        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
        "jiti": ">=1.21.0",
        "less": "*",
        "lightningcss": "^1.21.0",
        "sass": "*",
        "sass-embedded": "*",
        "stylus": "*",
        "sugarss": "*",
        "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-plugin-static-copy": {
      "version": "2.3.2",
      "resolved": "https://registry.npmmirror.com/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.2.tgz",
      "integrity": "sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==",
      "dev": true,
      "dependencies": {
        "chokidar": "^3.5.3",
        "fast-glob": "^3.2.11",
        "fs-extra": "^11.1.0",
        "p-map": "^7.0.3",
        "picocolors": "^1.0.0"
      },
      "engines": {
        "node": "^18.0.0 || >=20.0.0"
      },
      "peerDependencies": {
        "vite": "^5.0.0 || ^6.0.0"
      }
    },
    "node_modules/vscode-uri": {
      "version": "3.1.0",
      "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
      "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
      "dev": true
    },
    "node_modules/vue": {
      "version": "3.5.41",
      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.41.tgz",
      "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
      "dependencies": {
        "@vue/compiler-dom": "3.5.41",
        "@vue/compiler-sfc": "3.5.41",
        "@vue/runtime-dom": "3.5.41",
        "@vue/server-renderer": "3.5.41",
        "@vue/shared": "3.5.41"
      },
      "peerDependencies": {
        "typescript": "*"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/vue-router": {
      "version": "4.6.4",
      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
      "dependencies": {
        "@vue/devtools-api": "^6.6.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/posva"
      },
      "peerDependencies": {
        "vue": "^3.5.0"
      }
    },
    "node_modules/vue-router/node_modules/@vue/devtools-api": {
      "version": "6.6.4",
      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
    },
    "node_modules/vue-tsc": {
      "version": "2.2.12",
      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
      "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
      "dev": true,
      "dependencies": {
        "@volar/typescript": "2.4.15",
        "@vue/language-core": "2.2.12"
      },
      "bin": {
        "vue-tsc": "bin/vue-tsc.js"
      },
      "peerDependencies": {
        "typescript": ">=5.0.0"
      }
    },
    "node_modules/vue-types": {
      "version": "3.0.2",
      "resolved": "https://registry.npmmirror.com/vue-types/-/vue-types-3.0.2.tgz",
      "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
      "dependencies": {
        "is-plain-object": "3.0.1"
      },
      "engines": {
        "node": ">=10.15.0"
      },
      "peerDependencies": {
        "vue": "^3.0.0"
      }
    },
    "node_modules/warning": {
      "version": "4.0.3",
      "resolved": "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz",
      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
      "dependencies": {
        "loose-envify": "^1.0.0"
      }
    }
  }
}
apps/workbench-console/package.json
New file
@@ -0,0 +1,26 @@
{
  "name": "geoai-workbench-console",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@ant-design/icons-vue": "^7.0.1",
    "ant-design-vue": "^4.2.6",
    "cesium": "1.126.0",
    "pinia": "^3.0.1",
    "vue": "^3.5.13",
    "vue-router": "^4.5.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.1",
    "typescript": "^5.7.2",
    "vite": "^6.0.5",
    "vite-plugin-static-copy": "^2.3.0",
    "vue-tsc": "^2.2.0"
  }
}
apps/workbench-console/src/App.vue
New file
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
  AppstoreOutlined,
  CheckCircleFilled,
  CompassOutlined,
  ExperimentOutlined
} from "@ant-design/icons-vue";
import { capabilities, verifiedCapabilities } from "@/data/capabilities";
const route = useRoute();
const router = useRouter();
const activeKey = computed(() => route.name === "overview" ? "overview" : String(route.params.id));
const plannedCapabilities = computed(() => capabilities.filter((item) => item.status !== "verified"));
function navigate(key: string) {
  if (key === "overview") router.push({ name: "overview" });
  else router.push({ name: "capability", params: { id: key } });
}
function handleMenuClick(info: { key: string | number }) {
  navigate(String(info.key));
}
</script>
<template>
  <a-layout class="application-frame">
    <a-layout-sider class="application-sider" :width="252" breakpoint="lg" collapsed-width="0">
      <div class="brand" @click="navigate('overview')">
        <span class="brand-mark">GW</span>
        <span><strong>GeoAI Workbench</strong><small>本地实验控制台</small></span>
      </div>
      <a-menu class="navigation-menu" theme="dark" mode="inline" :selected-keys="[activeKey]" @click="handleMenuClick">
        <a-menu-item key="overview"><AppstoreOutlined /><span>实验总览</span></a-menu-item>
        <a-menu-divider />
        <a-menu-item-group key="verified" title="已验证能力">
          <a-menu-item v-for="capability in verifiedCapabilities" :key="capability.id">
            <ExperimentOutlined /><span>{{ capability.title }}</span>
          </a-menu-item>
        </a-menu-item-group>
        <a-menu-divider />
        <a-menu-item-group key="planned" title="能力目录">
          <a-menu-item v-for="capability in plannedCapabilities" :key="capability.id">
            <CompassOutlined /><span>{{ capability.title }}</span>
          </a-menu-item>
        </a-menu-item-group>
      </a-menu>
      <div class="sider-status">
        <CheckCircleFilled />
        <div><strong>仅本机读取</strong><small>无产品连接或外部服务</small></div>
      </div>
    </a-layout-sider>
    <a-layout class="content-layout"><a-layout-content><router-view /></a-layout-content></a-layout>
  </a-layout>
</template>
apps/workbench-console/src/api/artifacts.ts
New file
@@ -0,0 +1,101 @@
export interface Detection {
  class_id: number;
  class_name: string;
  confidence: number;
  bbox_xyxy: number[];
}
export interface DetectionImage {
  file: string;
  annotated_file: string;
  width: number;
  height: number;
  detections: Detection[];
}
export interface DetectionRun {
  detection_count: number;
  processed_images: number;
  elapsed_seconds: number;
  device: string;
  model: string;
  confidence: number;
  input_dir: string;
  notes: string[];
}
export interface TrajectoryEvent {
  event_id: string;
  event_type: string;
  track_ids: string[];
  start_time: string;
  end_time: string;
  duration_seconds: number;
}
export interface TrajectoryRun {
  case_count: number;
  track_count: number;
  event_count: number;
  dropped_duplicate_observations: number;
  elapsed_seconds: number;
  device: string;
  input: string;
  thresholds_by_case: Record<string, Record<string, number>>;
}
export interface TrajectorySummary {
  track_id: string;
  entity_type: string;
  point_count: string;
  distance_m: string;
  average_speed_mps: string;
  behavior_labels: string;
}
export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`;
async function getJson<T>(path: string): Promise<T> {
  const response = await fetch(artifactUrl(path), { cache: "no-store" });
  if (!response.ok) throw new Error(`无法读取 ${path} (${response.status})`);
  return response.json() as Promise<T>;
}
async function getText(path: string): Promise<string> {
  const response = await fetch(artifactUrl(path), { cache: "no-store" });
  if (!response.ok) throw new Error(`无法读取 ${path} (${response.status})`);
  return response.text();
}
function parseCsv(text: string): TrajectorySummary[] {
  const lines = text.trim().split(/\r?\n/);
  const headers = lines.shift()?.replace(/^\uFEFF/, "").split(",") ?? [];
  return lines.filter(Boolean).map((line) => {
    const values = line.split(",");
    return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])) as unknown as TrajectorySummary;
  });
}
export async function loadDetectionArtifacts() {
  const [run, payload] = await Promise.all([
    getJson<DetectionRun>("shared/outputs/01-object-detection/run_metadata.json"),
    getJson<{ images: DetectionImage[] }>("shared/outputs/01-object-detection/detections.json")
  ]);
  return { run, images: payload.images };
}
export async function loadTrajectoryArtifacts() {
  const [run, difficultEvents, difficultSummary, normalSummary] = await Promise.all([
    getJson<TrajectoryRun>("shared/outputs/15-trajectory-analysis/run_metadata.json"),
    getJson<{ events: TrajectoryEvent[] }>("shared/outputs/15-trajectory-analysis/difficult/events.json"),
    getText("shared/outputs/15-trajectory-analysis/difficult/trajectory_summary.csv"),
    getText("shared/outputs/15-trajectory-analysis/normal/trajectory_summary.csv")
  ]);
  return {
    run,
    cases: {
      difficult: { events: difficultEvents.events, summary: parseCsv(difficultSummary) },
      normal: { events: [], summary: parseCsv(normalSummary) }
    }
  };
}
apps/workbench-console/src/components/ArtifactState.vue
New file
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { LoadingOutlined } from "@ant-design/icons-vue";
defineProps<{ loading: boolean; error: string | null }>();
</script>
<template>
  <a-alert v-if="error" type="error" show-icon :message="error" description="请确认只读结果服务已在 127.0.0.1:6173 运行,且该能力已有输出文件。" />
  <div v-else-if="loading" class="loading-block"><LoadingOutlined spin /><span>正在读取本地实验工件...</span></div>
</template>
apps/workbench-console/src/components/ObjectDetectionPanel.vue
New file
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { FileImageOutlined, InfoCircleOutlined } from "@ant-design/icons-vue";
import { artifactUrl } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
const store = useArtifactStore();
const mode = ref<"annotated" | "original">("annotated");
const selectedName = ref("");
const selectedImage = computed(() => store.detectionImages.find((item) => item.file === selectedName.value) ?? store.detectionImages[0]);
const previewSource = computed(() => {
  if (!selectedImage.value) return "";
  const path = mode.value === "annotated"
    ? `shared/outputs/01-object-detection/${selectedImage.value.annotated_file}`
    : `shared/data/raw/01-object-detection/${selectedImage.value.file}`;
  return artifactUrl(path);
});
onMounted(async () => {
  await store.loadDetection();
  const mostDetections = [...store.detectionImages].sort((left, right) => right.detections.length - left.detections.length)[0];
  selectedName.value = mostDetections?.file ?? "";
});
</script>
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <template v-if="store.detectionRun && selectedImage">
    <a-row :gutter="[18, 18]">
      <a-col :xs="24" :xl="17"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="store.detectionImages.map((item) => ({ value: item.file, label: `${item.file} · ${item.detections.length} 个候选` }))" /><a-segmented v-model:value="mode" :options="[{ label: '标注结果', value: 'annotated' }, { label: '原始影像', value: 'original' }]" /></div><a-image class="detection-image" :src="previewSource" :alt="`${selectedImage.file} 预览`" /><p class="image-caption">{{ selectedImage.width }} x {{ selectedImage.height }} 像素 · {{ selectedImage.detections.length }} 个候选</p></section></a-col>
      <a-col :xs="24" :xl="7"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="处理影像">{{ store.detectionRun.processed_images }} 张</a-descriptions-item><a-descriptions-item label="检测候选">{{ store.detectionRun.detection_count }} 个</a-descriptions-item><a-descriptions-item label="耗时">{{ store.detectionRun.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ store.detectionRun.device }}</a-descriptions-item><a-descriptions-item label="模型">{{ store.detectionRun.model }}</a-descriptions-item><a-descriptions-item label="阈值">{{ store.detectionRun.confidence }}</a-descriptions-item></a-descriptions><a-divider /><a-space direction="vertical"><a-button type="link" :href="artifactUrl('shared/outputs/01-object-detection/detections.json')" target="_blank"><FileImageOutlined />检测 JSON</a-button><a-button type="link" :href="artifactUrl('shared/outputs/01-object-detection/run_metadata.json')" target="_blank"><InfoCircleOutlined />运行元数据</a-button></a-space></section></a-col>
    </a-row>
    <section class="surface-section"><div class="section-heading"><div><h2>当前影像检测明细</h2><p>边界框是 JPEG 像素坐标,不含可用地理坐标。</p></div></div><a-table :data-source="selectedImage.detections" :pagination="false" row-key="bbox_xyxy" size="small" :scroll="{ x: 680 }"><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="置信度" key="confidence" align="right"><template #default="{ record }">{{ (record.confidence * 100).toFixed(1) }}%</template></a-table-column><a-table-column title="像素框 x1, y1, x2, y2" key="bbox"><template #default="{ record }">{{ record.bbox_xyxy.map((value: number) => value.toFixed(1)).join(', ') }}</template></a-table-column><template #emptyText>该影像没有达到当前阈值的候选目标。</template></a-table></section>
  </template>
</template>
apps/workbench-console/src/components/PageHeader.vue
New file
@@ -0,0 +1,10 @@
<script setup lang="ts">
defineProps<{ eyebrow: string; title: string; description: string }>();
</script>
<template>
  <header class="page-header">
    <div><p class="eyebrow">{{ eyebrow }}</p><h1>{{ title }}</h1><p class="page-description">{{ description }}</p></div>
    <a-tag class="local-tag" color="green">本机只读</a-tag>
  </header>
</template>
apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue
New file
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref } from "vue";
import { DownloadOutlined } from "@ant-design/icons-vue";
import { artifactUrl } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
const TrajectoryMap = defineAsyncComponent(() => import("@/components/TrajectoryMap.vue"));
const store = useArtifactStore();
const caseId = ref<"difficult" | "normal">("difficult");
const currentCase = computed(() => store.trajectoryCases[caseId.value] ?? { events: [], summary: [] });
const eventNames: Record<string, string> = { stop: "停留", route_deviation: "偏航", restricted_zone: "进入禁入区", gathering: "聚集" };
const caseNote = computed(() => caseId.value === "difficult" ? "含停留、偏航、禁入区、聚集与重复观测。" : "连续移动且贴合参考路线,预期无事件。" );
const difficultRules = computed(() => store.trajectoryRun?.thresholds_by_case.difficult ?? {});
onMounted(() => store.loadTrajectory());
</script>
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <template v-if="store.trajectoryRun">
    <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="17"><section class="surface-section"><div class="section-toolbar"><a-segmented v-model:value="caseId" :options="[{ label: '困难样本', value: 'difficult' }, { label: '正常样本', value: 'normal' }]" /><span class="toolbar-note">{{ caseNote }}</span></div><TrajectoryMap :case-id="caseId" /></section></a-col><a-col :xs="24" :xl="7"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="案例">{{ store.trajectoryRun.case_count }} 个</a-descriptions-item><a-descriptions-item label="轨迹">{{ store.trajectoryRun.track_count }} 条</a-descriptions-item><a-descriptions-item label="事件">{{ store.trajectoryRun.event_count }} 个</a-descriptions-item><a-descriptions-item label="重复清洗">{{ store.trajectoryRun.dropped_duplicate_observations }} 条</a-descriptions-item><a-descriptions-item label="耗时">{{ store.trajectoryRun.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ store.trajectoryRun.device }}</a-descriptions-item></a-descriptions><a-divider /><h3>默认规则</h3><a-descriptions size="small" :column="1"><a-descriptions-item label="停留速度">{{ difficultRules.stop_speed_mps }} m/s</a-descriptions-item><a-descriptions-item label="偏航距离">{{ difficultRules.route_deviation_m }} m</a-descriptions-item><a-descriptions-item label="聚集距离">{{ difficultRules.gathering_radius_m }} m</a-descriptions-item></a-descriptions></section></a-col></a-row>
    <a-row :gutter="[18, 18]" class="result-row"><a-col :xs="24" :xl="10"><section class="surface-section"><div class="section-heading"><div><h2>事件记录</h2><p>{{ currentCase.events.length }} 个规则事件</p></div></div><a-empty v-if="!currentCase.events.length" description="正常样本未触发规则事件" /><a-timeline v-else><a-timeline-item v-for="event in currentCase.events" :key="event.event_id" :color="event.event_type === 'gathering' ? 'green' : event.event_type === 'restricted_zone' ? 'gold' : event.event_type === 'route_deviation' ? 'purple' : 'red'"><strong>{{ eventNames[event.event_type] || event.event_type }}</strong><p>{{ event.track_ids.join('、') }}</p><small>{{ new Date(event.start_time).toLocaleString('zh-CN', { hour12: false }) }} · {{ event.duration_seconds }} 秒</small></a-timeline-item></a-timeline></section></a-col><a-col :xs="24" :xl="14"><section class="surface-section"><div class="section-heading"><div><h2>轨迹汇总</h2><p>聚类 ID 仅用于探索相似轨迹,不代表确认异常。</p></div></div><a-table :data-source="currentCase.summary" :pagination="false" row-key="track_id" size="small" :scroll="{ x: 760 }"><a-table-column title="轨迹" data-index="track_id" key="track_id" /><a-table-column title="对象" data-index="entity_type" key="entity_type" /><a-table-column title="点数" data-index="point_count" key="point_count" align="right" /><a-table-column title="距离" key="distance" align="right"><template #default="{ record }">{{ Number(record.distance_m).toFixed(1) }} m</template></a-table-column><a-table-column title="均速" key="speed" align="right"><template #default="{ record }">{{ Number(record.average_speed_mps).toFixed(2) }} m/s</template></a-table-column><a-table-column title="行为标签" data-index="behavior_labels" key="behavior_labels" /></a-table></section></a-col></a-row>
    <section class="surface-section result-files"><a-space wrap><a-button v-for="file in ['events.json', 'events.geojson', 'trajectories.geojson', 'run_metadata.json']" :key="file" :href="artifactUrl(`shared/outputs/15-trajectory-analysis/${caseId}/${file}`)" target="_blank"><DownloadOutlined />{{ file }}</a-button></a-space></section>
  </template>
</template>
apps/workbench-console/src/components/TrajectoryMap.vue
New file
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
  Color,
  ColorMaterialProperty,
  ConstantProperty,
  GeoJsonDataSource,
  GridImageryProvider,
  HeightReference,
  PointGraphics,
  Viewer
} from "cesium";
import { artifactUrl } from "@/api/artifacts";
const props = defineProps<{ caseId: "difficult" | "normal" }>();
const host = ref<HTMLElement>();
const mapError = ref<string | null>(null);
let viewer: Viewer | null = null;
let trajectorySource: GeoJsonDataSource | null = null;
let eventSource: GeoJsonDataSource | null = null;
function eventColor(type: string) {
  if (type === "route_deviation") return Color.fromCssColorString("#9a5da8");
  if (type === "restricted_zone") return Color.fromCssColorString("#bc861a");
  if (type === "gathering") return Color.fromCssColorString("#176b50");
  return Color.fromCssColorString("#c6533f");
}
async function drawCase() {
  if (!viewer) return;
  try {
    mapError.value = null;
    if (trajectorySource) viewer.dataSources.remove(trajectorySource, true);
    if (eventSource) viewer.dataSources.remove(eventSource, true);
    const root = `shared/outputs/15-trajectory-analysis/${props.caseId}`;
    trajectorySource = await GeoJsonDataSource.load(artifactUrl(`${root}/trajectories.geojson`), { clampToGround: false });
    trajectorySource.entities.values.forEach((entity) => {
      if (entity.polyline) {
        entity.polyline.width = new ConstantProperty(4);
        entity.polyline.material = new ColorMaterialProperty(Color.fromCssColorString("#176b50"));
      }
    });
    eventSource = await GeoJsonDataSource.load(artifactUrl(`${root}/events.geojson`), { clampToGround: false });
    eventSource.entities.values.forEach((entity) => {
      const type = String(entity.properties?.event_type?.getValue() ?? "stop");
      entity.point = new PointGraphics({
        pixelSize: new ConstantProperty(12),
        color: new ConstantProperty(eventColor(type)),
        outlineColor: new ConstantProperty(Color.WHITE),
        outlineWidth: new ConstantProperty(2),
        heightReference: new ConstantProperty(HeightReference.NONE)
      });
    });
    await viewer.dataSources.add(trajectorySource);
    await viewer.dataSources.add(eventSource);
    await viewer.flyTo(trajectorySource, { duration: 0 });
  } catch (error) {
    mapError.value = error instanceof Error ? error.message : "Cesium 地图加载失败";
  }
}
onMounted(async () => {
  if (!host.value) return;
  viewer = new Viewer(host.value, {
    animation: false,
    baseLayerPicker: false,
    fullscreenButton: false,
    geocoder: false,
    homeButton: false,
    infoBox: false,
    navigationHelpButton: false,
    sceneModePicker: false,
    selectionIndicator: false,
    timeline: false
  });
  viewer.imageryLayers.removeAll();
  viewer.imageryLayers.addImageryProvider(new GridImageryProvider({ cells: 8, glowWidth: 0 }));
  await drawCase();
});
watch(() => props.caseId, drawCase);
onBeforeUnmount(() => viewer?.destroy());
</script>
<template>
  <div class="trajectory-map"><div ref="host" class="cesium-host"></div><a-alert v-if="mapError" class="map-error" type="warning" show-icon :message="mapError" /></div>
</template>
apps/workbench-console/src/data/capabilities.ts
New file
@@ -0,0 +1,34 @@
export type CapabilityStatus = "verified" | "existing" | "planned";
export interface CapabilityRecord {
  id: string;
  title: string;
  level: "A" | "B" | "C";
  status: CapabilityStatus;
  note: string;
}
export const capabilities: CapabilityRecord[] = [
  { id: "00-change-detection", title: "变化检测", level: "A", status: "existing", note: "现有产品能力已确认;本地 Demo 尚未实现。" },
  { id: "01-object-detection", title: "地物目标检测", level: "A", status: "verified", note: "人员与车辆实验可运行,含标注影像和结构化检测结果。" },
  { id: "02-semantic-mapping", title: "语义制图", level: "A", status: "planned", note: "等待首个本地 Demo。" },
  { id: "03-attribute-classification", title: "属性分类", level: "A", status: "planned", note: "等待首个本地 Demo。" },
  { id: "04-spatial-measurement", title: "空间测量", level: "B", status: "planned", note: "等待首个本地 Demo。" },
  { id: "05-3d-pointcloud", title: "三维点云", level: "B", status: "planned", note: "等待首个本地 Demo。" },
  { id: "06-spatial-reasoning", title: "空间推理", level: "C", status: "planned", note: "等待首个本地 Demo。" },
  { id: "07-risk-rule-engine", title: "风险规则引擎", level: "C", status: "planned", note: "等待首个本地 Demo。" },
  { id: "08-spatiotemporal-forecasting", title: "时空预测", level: "B", status: "planned", note: "等待首个本地 Demo。" },
  { id: "09-anomaly-detection", title: "异常检测", level: "B", status: "planned", note: "等待首个本地 Demo。" },
  { id: "10-smart-route-planning", title: "智能航线规划", level: "C", status: "planned", note: "等待首个本地 Demo。" },
  { id: "11-fleet-scheduling", title: "集群调度", level: "C", status: "planned", note: "等待首个本地 Demo。" },
  { id: "12-quality-control-refly", title: "质量检查与补飞", level: "B", status: "planned", note: "等待首个本地 Demo。" },
  { id: "13-asset-health-diagnosis", title: "资产健康诊断", level: "C", status: "planned", note: "等待首个本地 Demo。" },
  { id: "14-disaster-response", title: "灾害响应", level: "B", status: "planned", note: "等待首个本地 Demo。" },
  { id: "15-trajectory-analysis", title: "轨迹分析与行为识别", level: "C", status: "verified", note: "规则事件与轨迹聚类 CPU Demo 已验证。" },
  { id: "16-geollm-assistant", title: "GeoLLM 助手", level: "C", status: "planned", note: "等待首个本地 Demo。" },
  { id: "17-spatiotemporal-knowledge-graph", title: "时空知识图谱", level: "C", status: "planned", note: "等待首个本地 Demo。" },
  { id: "18-reporting-model-governance", title: "报告与模型治理", level: "C", status: "planned", note: "等待首个本地 Demo。" }
];
export const verifiedCapabilities = capabilities.filter((item) => item.status === "verified");
export const capabilityById = (id: string) => capabilities.find((item) => item.id === id);
apps/workbench-console/src/main.ts
New file
@@ -0,0 +1,11 @@
import { createApp } from "vue";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import "cesium/Build/Cesium/Widgets/widgets.css";
import "./styles.css";
import App from "./App.vue";
import { router } from "./router";
import { pinia } from "./stores";
createApp(App).use(pinia).use(router).use(Antd).mount("#app");
apps/workbench-console/src/router/index.ts
New file
@@ -0,0 +1,13 @@
import { createRouter, createWebHashHistory } from "vue-router";
import CapabilityView from "@/views/CapabilityView.vue";
import OverviewView from "@/views/OverviewView.vue";
export const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    { path: "/", name: "overview", component: OverviewView },
    { path: "/capability/:id", name: "capability", component: CapabilityView },
    { path: "/:pathMatch(.*)*", redirect: "/" }
  ]
});
apps/workbench-console/src/stores/artifacts.ts
New file
@@ -0,0 +1,61 @@
import { defineStore } from "pinia";
import {
  loadDetectionArtifacts,
  loadTrajectoryArtifacts,
  type DetectionImage,
  type DetectionRun,
  type TrajectoryRun,
  type TrajectorySummary,
  type TrajectoryEvent
} from "@/api/artifacts";
interface ArtifactState {
  detectionRun: DetectionRun | null;
  detectionImages: DetectionImage[];
  trajectoryRun: TrajectoryRun | null;
  trajectoryCases: Record<string, { events: TrajectoryEvent[]; summary: TrajectorySummary[] }>;
  loading: boolean;
  error: string | null;
}
export const useArtifactStore = defineStore("artifacts", {
  state: (): ArtifactState => ({
    detectionRun: null,
    detectionImages: [],
    trajectoryRun: null,
    trajectoryCases: {},
    loading: false,
    error: null
  }),
  actions: {
    async loadDetection() {
      if (this.detectionRun) return;
      this.loading = true;
      this.error = null;
      try {
        const result = await loadDetectionArtifacts();
        this.detectionRun = result.run;
        this.detectionImages = result.images;
      } catch (error) {
        this.error = error instanceof Error ? error.message : "目标检测结果读取失败";
      } finally {
        this.loading = false;
      }
    },
    async loadTrajectory() {
      if (this.trajectoryRun) return;
      this.loading = true;
      this.error = null;
      try {
        const result = await loadTrajectoryArtifacts();
        this.trajectoryRun = result.run;
        this.trajectoryCases = result.cases;
      } catch (error) {
        this.error = error instanceof Error ? error.message : "轨迹分析结果读取失败";
      } finally {
        this.loading = false;
      }
    }
  }
});
apps/workbench-console/src/stores/index.ts
New file
@@ -0,0 +1,3 @@
import { createPinia } from "pinia";
export const pinia = createPinia();
apps/workbench-console/src/styles.css
New file
@@ -0,0 +1,17 @@
:root { color: #1b2620; background: #f3f5f1; font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif; font-synthesis: none; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #f3f5f1; }
.application-frame { min-height: 100vh; }
.application-sider { position: sticky !important; top: 0; height: 100vh; background: #1e2923 !important; border-right: 1px solid #152019; }
.brand { display: flex; align-items: center; gap: 10px; height: 82px; padding: 18px 20px; color: #f4f9f5; cursor: pointer; }
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; background: #dcf0e4; color: #195d43; font-size: 12px; font-weight: 800; }
.brand strong, .brand small { display: block; }.brand strong { font-size: 14px; letter-spacing: 0; }.brand small { color: #aac0b0; font-size: 11px; }
.navigation-menu { height: calc(100vh - 150px); overflow-y: auto; background: #1e2923 !important; border-inline-end: 0 !important; }.navigation-menu .ant-menu-item-group-title { color: #9eb2a4 !important; font-size: 11px; font-weight: 700; letter-spacing: 0; }.navigation-menu .ant-menu-item { margin-inline: 0 !important; width: 100% !important; border-radius: 0 !important; }.navigation-menu .ant-menu-item-selected { background: #31443a !important; }.navigation-menu .ant-menu-item::after { border-inline-end-color: #79c99e !important; }
.sider-status { position: absolute; right: 18px; bottom: 18px; left: 18px; display: flex; gap: 9px; padding-top: 14px; color: #dce8e0; border-top: 1px solid #42534a; }.sider-status .anticon { margin-top: 4px; color: #79c99e; }.sider-status strong, .sider-status small { display: block; }.sider-status strong { font-size: 12px; }.sider-status small { color: #a9bcb0; font-size: 11px; }
.content-layout { background: #f3f5f1 !important; }.view-container { width: min(1460px, 100%); margin: 0 auto; padding: 34px 42px 58px; }.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }.eyebrow { margin: 0 0 6px; color: #176b50; font-size: 12px; font-weight: 700; letter-spacing: 0; }.page-header h1 { margin: 0 0 8px; color: #19231e; font-size: 29px; line-height: 1.2; letter-spacing: 0; }.page-description { max-width: 800px; margin: 0; color: #66716b; }.local-tag { margin: 2px 0 0 !important; border-radius: 3px !important; }
.loading-block { display: flex; gap: 9px; align-items: center; justify-content: center; min-height: 150px; color: #66716b; }.metric-row { margin-bottom: 30px; }.metric-row .ant-statistic { min-height: 106px; padding: 17px; background: #ffffff; border: 1px solid #d9e0da; box-shadow: 0 9px 25px rgba(20, 43, 30, 0.06); }.metric-row .ant-statistic-content { color: #1c392b; font-size: 29px; }.view-section { margin-top: 32px; }.section-heading { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; }.section-heading h2, .surface-section h2 { margin: 0 0 4px; color: #202b25; font-size: 18px; letter-spacing: 0; }.section-heading p, .surface-section p { margin: 0; color: #66716b; font-size: 12px; }
.capability-card { overflow: hidden; border-radius: 4px !important; border-color: #d9e0da !important; box-shadow: 0 9px 25px rgba(20, 43, 30, 0.07) !important; }.card-media { display: block; width: 100%; height: 260px; object-fit: cover; background: #e0e6e1; }.card-copy { padding: 18px; }.card-topline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }.card-topline h3 { margin: 0 0 6px; color: #202b25; font-size: 16px; letter-spacing: 0; }.card-topline p, .planned-card p { color: #66716b; }.card-copy .ant-space { display: flex; min-height: 40px; align-content: flex-start; margin-top: 14px; }.card-copy .ant-btn { margin-top: 8px; }.planned-card { height: 100%; border-radius: 4px !important; border-color: #d9e0da !important; }.planned-card p { min-height: 38px; margin: 7px 0 8px; font-size: 12px; }
.surface-section { margin-bottom: 18px; padding: 20px; background: #ffffff; border: 1px solid #d9e0da; box-shadow: 0 9px 25px rgba(20, 43, 30, 0.06); }.section-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 13px; }.section-toolbar .ant-select { width: min(65%, 540px); }.detection-image { display: block; width: 100%; min-height: 390px; max-height: 660px; overflow: hidden; background: #232e28; text-align: center; }.detection-image .ant-image-img { width: 100%; max-height: 660px; object-fit: contain; }.image-caption { padding-top: 9px; }.surface-section .ant-descriptions { font-size: 12px; }.surface-section .ant-descriptions-item-label { color: #66716b; }.surface-section h3 { margin: 0 0 8px; font-size: 14px; }.result-row { margin-top: 0; }.result-files { margin-bottom: 0; }.toolbar-note { color: #66716b; font-size: 12px; text-align: right; }
.trajectory-map { position: relative; height: 510px; overflow: hidden; background: #1b2620; }.cesium-host { width: 100%; height: 100%; }.map-error { position: absolute; right: 14px; bottom: 14px; left: 14px; }.cesium-viewer-bottom { display: none !important; }.cesium-viewer-toolbar { top: 10px; right: 10px; }.ant-empty { margin: 70px 0; }.ant-empty-description p { color: #66716b; }
@media (max-width: 991px) { .application-sider { position: static !important; height: auto; }.navigation-menu { height: auto; max-height: 290px; }.sider-status { display: none; }.view-container { padding: 25px 22px 42px; }.detection-image { min-height: 270px; }.trajectory-map { height: 440px; } }
@media (max-width: 640px) { .view-container { padding: 20px 14px 34px; }.page-header, .section-toolbar { align-items: stretch; flex-direction: column; }.page-header h1 { font-size: 24px; }.local-tag { align-self: flex-start; }.section-toolbar .ant-select { width: 100%; }.toolbar-note { text-align: left; }.surface-section { padding: 14px; }.trajectory-map { height: 380px; }.card-media { height: 205px; } }
apps/workbench-console/src/views/CapabilityView.vue
New file
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import PageHeader from "@/components/PageHeader.vue";
import ObjectDetectionPanel from "@/components/ObjectDetectionPanel.vue";
import TrajectoryAnalysisPanel from "@/components/TrajectoryAnalysisPanel.vue";
import { capabilityById } from "@/data/capabilities";
const route = useRoute();
const capability = computed(() => capabilityById(String(route.params.id)));
</script>
<template>
  <div class="view-container" v-if="capability">
    <PageHeader :eyebrow="`CAPABILITY ${capability.id} / ${capability.level}`" :title="capability.title" :description="capability.note" />
    <ObjectDetectionPanel v-if="capability.id === '01-object-detection'" />
    <TrajectoryAnalysisPanel v-else-if="capability.id === '15-trajectory-analysis'" />
    <a-empty v-else description="等待第一个可验证 Demo"><template #description><h2>尚无本地结果工件</h2><p>该能力已登记在目录中,但尚未产生可视化结果、结构化输出或运行元数据。</p></template></a-empty>
  </div>
  <div v-else class="view-container"><a-result status="404" title="未找到能力页面" sub-title="请从左侧目录选择已登记的能力。" /></div>
</template>
apps/workbench-console/src/views/OverviewView.vue
New file
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { EyeOutlined, ExperimentOutlined } from "@ant-design/icons-vue";
import ArtifactState from "@/components/ArtifactState.vue";
import PageHeader from "@/components/PageHeader.vue";
import { capabilities, verifiedCapabilities } from "@/data/capabilities";
import { artifactUrl } from "@/api/artifacts";
import { useArtifactStore } from "@/stores/artifacts";
const store = useArtifactStore();
const router = useRouter();
const planned = computed(() => capabilities.filter((item) => item.status === "planned"));
onMounted(async () => Promise.all([store.loadDetection(), store.loadTrajectory()]));
function openCapability(id: string) { router.push({ name: "capability", params: { id } }); }
</script>
<template>
  <div class="view-container"><PageHeader eyebrow="WORKBENCH / OVERVIEW" title="实验结果一眼可见" description="独立于无人机产品的本地控制台。它只读取当前工作区已有结果,不上传数据,也不会触发算法运行。" /><ArtifactState :loading="store.loading" :error="store.error" />
    <a-row :gutter="[14, 14]" class="metric-row"><a-col :xs="12" :lg="6"><a-statistic title="能力目录" :value="capabilities.length" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="已验证 Demo" :value="verifiedCapabilities.length" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="最近检测结果" :value="store.detectionRun?.detection_count ?? 0" suffix="个" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="最近轨迹事件" :value="store.trajectoryRun?.event_count ?? 0" suffix="个" /></a-col></a-row>
    <section class="view-section"><div class="section-heading"><div><h2>已验证能力</h2><p>从实验结果进入,不需要再查找输出目录。</p></div></div><a-row :gutter="[18, 18]"><a-col :xs="24" :xl="12"><a-card class="capability-card" :body-style="{ padding: '0' }"><img class="card-media" :src="artifactUrl('shared/outputs/01-object-detection/annotated/DJI_20260810092727_0001_V_10.jpeg')" alt="目标检测标注图" /><div class="card-copy"><div class="card-topline"><div><h3>地物目标检测</h3><p>原图与标注图切换、检测框明细与运行参数。</p></div><a-tag color="green">已验证</a-tag></div><a-space wrap><a-tag color="gold">A:直接能力</a-tag><a-tag>{{ store.detectionRun?.processed_images ?? 0 }} 张影像</a-tag><a-tag>{{ store.detectionRun?.detection_count ?? 0 }} 个候选</a-tag></a-space><a-button type="primary" @click="openCapability('01-object-detection')"><EyeOutlined />查看实验结果</a-button></div></a-card></a-col><a-col :xs="24" :xl="12"><a-card class="capability-card" :body-style="{ padding: '0' }"><img class="card-media" :src="artifactUrl('shared/outputs/15-trajectory-analysis/difficult/analysis.png')" alt="轨迹分析图" /><div class="card-copy"><div class="card-topline"><div><h3>轨迹分析与行为识别</h3><p>Cesium 轨迹地图、规则事件和轨迹汇总。</p></div><a-tag color="green">已验证</a-tag></div><a-space wrap><a-tag color="gold">C:产品能力</a-tag><a-tag>{{ store.trajectoryRun?.track_count ?? 0 }} 条轨迹</a-tag><a-tag>{{ store.trajectoryRun?.event_count ?? 0 }} 个事件</a-tag></a-space><a-button type="primary" @click="openCapability('15-trajectory-analysis')"><EyeOutlined />查看实验结果</a-button></div></a-card></a-col></a-row></section>
    <section class="view-section"><div class="section-heading"><div><h2>待实现能力</h2><p>首个 Demo 产生可检查工件后,可以按相同方式接入控制台。</p></div></div><a-row :gutter="[14, 14]"><a-col v-for="capability in planned.slice(0, 8)" :key="capability.id" :xs="24" :md="12" :xl="6"><a-card size="small" class="planned-card"><div class="card-topline"><h3>{{ capability.title }}</h3><a-tag>{{ capability.level }}</a-tag></div><p>{{ capability.note }}</p><a-button type="link" size="small" @click="openCapability(capability.id)"><ExperimentOutlined />查看状态</a-button></a-card></a-col></a-row></section>
  </div>
</template>
apps/workbench-console/tsconfig.app.json
New file
@@ -0,0 +1,21 @@
{
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["src/**/__tests__/*"],
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2021",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2021", "DOM", "DOM.Iterable"],
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "preserve",
    "strict": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  }
}
apps/workbench-console/tsconfig.json
New file
@@ -0,0 +1,7 @@
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}
apps/workbench-console/tsconfig.node.json
New file
@@ -0,0 +1,12 @@
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["vite.config.ts"]
}
apps/workbench-console/vite.config.ts
New file
@@ -0,0 +1,39 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { viteStaticCopy } from "vite-plugin-static-copy";
const consoleBase = "/apps/workbench-console/";
export default defineConfig({
  base: consoleBase,
  define: {
    CESIUM_BASE_URL: JSON.stringify(`${consoleBase}cesium`)
  },
  plugins: [
    vue(),
    viteStaticCopy({
      targets: [
        { src: "node_modules/cesium/Build/Cesium/Workers", dest: "cesium" },
        { src: "node_modules/cesium/Build/Cesium/ThirdParty", dest: "cesium" },
        { src: "node_modules/cesium/Build/Cesium/Assets", dest: "cesium" },
        { src: "node_modules/cesium/Build/Cesium/Widgets", dest: "cesium" }
      ]
    })
  ],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
      "@zip.js/zip.js/lib/zip-no-worker.js": fileURLToPath(new URL("./node_modules/@zip.js/zip.js/index.js", import.meta.url))
    }
  },
  server: {
    host: "127.0.0.1",
    port: 6174,
    strictPort: true,
    proxy: {
      "/shared": "http://127.0.0.1:6173"
    }
  }
});
baseData/no-fly-zone-Chn_xS2X.geojson
New file
Diff too large
baseData/uom-fly-zone.bin-B8O-S8Cs.gzip
Binary files differ
baseData/演示.xlsx
Binary files differ
baseData/田墩-疫木识别(勿删).kmz
Binary files differ
capabilities/01-object-detection/README.md
@@ -1,6 +1,101 @@
# Object Detection
# 地物目标检测
- 输入:无人机照片或视频帧。
- 输出:目标框、类别、置信度和位置。
- 首个 Demo:10–30 张图片的预训练模型推理与 JSON 导出。
## 目标
先做一个可在本机 CPU 运行的预训练模型 Demo:输入无人机照片,输出带目标框、类别和置信度的标注图片,以及结构化 JSON。第二阶段再支持正射 GeoTIFF 切片,并把像素坐标转换为经纬度后输出 GeoJSON,便于接入现有 Cesium 地图和算法管理模块。
本能力使用 [opengeos/geoai](https://github.com/opengeos/geoai) 发布的 `geoai-py` 作为 GeoAI 工作流层。它负责把地理影像处理、AI 推理、地理结果和可视化串起来;PyTorch、Rasterio、GeoPandas 等仍是它和本 Demo 的底层运行依赖。
## 当前范围
首版优先识别人员、车辆。`geoai-py` 自带的 NWPU-VHR10 预训练模型面向航空影像,并提供统一的 `vehicle` 类,但不包含人员和树木;因此当前采用“切片 YOLO 检人员 + GeoAI NWPU 检车辆”的分支方案。树木通常需要专门的树冠/单木数据集和模型微调。
## 需要你准备的内容
1. **目标清单**:请先确定最关心的 3–5 类目标,例如人员、车辆、挖掘机、船只、建筑物。
2. **样例图片**:准备 10–30 张无人机 JPG/PNG,尽量包含不同高度、角度、天气和目标大小。不要使用涉密或未经授权的照片。
3. **正射样例(第二阶段)**:准备 1–2 份 GeoTIFF,并记录坐标系、分辨率和拍摄时间。
4. **输出接入约定**:确认结果需要保存为 JSON/GeoJSON,还是还要生成 Cesium 可加载的 GeoJSON/KML。
5. **验收样例**:从图片中挑选 5 张作为固定验收集,不参与后续调参。
## 目录约定
```text
01-object-detection/
|-- README.md
|-- requirements.txt
|-- src/                 # 推理和结果转换代码
|-- scripts/             # 一键运行脚本
|-- tests/               # 单元测试
shared/
|-- data/raw/01-object-detection/       # 原始图片,只存本地
|-- data/processed/01-object-detection/ # 切片或预处理结果
|-- models/01-object-detection/         # 模型权重,不提交 Git
`-- outputs/01-object-detection/        # 标注图、JSON、GeoJSON
```
## 环境与硬件
- 使用 Python 3.12 专用环境:`.venvs/01-object-detection`。
- 当前电脑是 AMD RX 590 GME 8GB,不具备 NVIDIA CUDA;首版使用 CPU 推理。
- 64GB 内存足够运行 10–30 张图片的 Demo。大尺寸正射影像需要切片,不能一次性全部载入内存。
- 模型权重和 Python 包必须记录版本与许可证,产品使用前检查是否允许商用。
- `geoai-py` 项目本身为 MIT 许可证;它依赖的模型权重、数据集和第三方库仍需分别核查许可证。`ultralytics` 等可选底层组件的许可证不能由 GeoAI 的 MIT 许可证自动覆盖。
## 预期输入输出
输入:JPG/PNG 图片,或第二阶段的 GeoTIFF。
输出:
- `annotated/`:画出检测框和标签的图片;
- `detections.json`:图片名、类别、置信度、像素框坐标;
- `detections.geojson`:正射影像场景下的地理框或中心点;
- `run_metadata.json`:模型版本、阈值、运行时间、设备(CPU/DirectML)。
## 首版验收标准
- 能通过命令行处理单张图片和一个图片目录;
- 输出图片能看到检测框、类别和置信度;
- JSON 字段固定且可被前端读取;
- 对固定 5 张验收图片,结果可重复生成;
- 无 GPU 时能正常运行,并对超大图片给出清晰错误提示或自动缩放;
- 运行日志包含模型、置信度阈值、输入数量、输出路径和耗时。
## 预计开发顺序
1. 创建专用环境并安装 PyTorch、torchvision、Ultralytics;
2. 下载并登记一个允许研究/商用核查的预训练权重;
3. 完成单图推理;
4. 完成目录批处理、标注图和 JSON 导出;
5. 用你的无人机图片验证并调整阈值;
6. 增加 GeoTIFF 切片和 GeoJSON 坐标转换;
7. 最后再做 FastAPI 接口和现有前端接入。
## 当前 Demo 运行命令
在项目根目录执行人员/通用车辆切片基线:
```powershell
.\.venvs\01-object-detection\Scripts\python.exe .\capabilities\01-object-detection\run_detection.py
```
默认使用 CPU、`yolo11n.pt`、1024 像素切片、20% 重叠和置信度阈值 0.20。
结果写入 `shared/outputs/01-object-detection/`。
执行 GeoAI NWPU-VHR10 航拍车辆检测:
```powershell
.\.venvs\01-object-detection\Scripts\python.exe .\capabilities\01-object-detection\run_geoai_vehicle_detection.py
```
该脚本默认使用 CPU、512 像素滑窗、128 像素重叠和置信度阈值 0.30,结果写入
`shared/outputs/01-object-detection/geoai-vehicles/`。首次运行会自动下载约 98 MB
的模型权重。
## 当前验证结论
- `DJI_20260810092727_0001_V_10.jpeg`:通用 YOLO 检出 2–3 辆车;GeoAI NWPU 检出 33 个车辆候选,CPU 推理约 90 秒,明显改善俯视小车辆漏检。
- `DJI_20260713102047_0001_V_19.jpeg`:切片 YOLO 检出多个人员,但两种模型均未检出右上角红色汽车;该近景车顶外观仍需要更匹配的航拍数据或本项目样本微调。
- NWPU 会产生少量其他航拍类别误检,正式 Demo 只保留 `vehicle`;脚本还会过滤被高置信度整车框大部分包含的重复局部框。
- 树木不属于当前两个模型的有效类别,必须单独建设树冠检测/分割分支。
capabilities/01-object-detection/requirements.txt
@@ -1,5 +1,9 @@
-r ../../requirements/base.txt
# GeoAI workflow layer: geospatial image preparation, detection helpers,
# georeferenced output and visualization utilities.
geoai-py>=0.42,<0.43
torch>=2.4,<3
torchvision>=0.19,<1
ultralytics>=8.3,<9
rasterio>=1.4,<2
geopandas>=1,<2
capabilities/01-object-detection/run_detection.py
New file
@@ -0,0 +1,256 @@
"""Run the first GeoAI object-detection baseline on drone images.
This baseline uses the opengeos/geoai environment plus a general-purpose
Ultralytics model. It intentionally keeps georeferenced GeoTIFF processing for
the next stage, because the current JPEGs do not carry usable GPS metadata.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from PIL import Image
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
COCO_TARGET_CLASS_IDS = [0, 1, 2, 3, 5, 7]
def parse_args() -> argparse.Namespace:
    root = Path(__file__).resolve().parents[2]
    default_input = root / "shared" / "data" / "raw" / "01-object-detection"
    default_output = root / "shared" / "outputs" / "01-object-detection"
    parser = argparse.ArgumentParser(description="Detect objects in drone images.")
    parser.add_argument("--input", type=Path, default=default_input)
    parser.add_argument("--output", type=Path, default=default_output)
    parser.add_argument("--model", default="yolo11n.pt", help="Ultralytics model name or path.")
    parser.add_argument("--confidence", type=float, default=0.20)
    parser.add_argument("--image-size", type=int, default=1024, help="Model input size for each tile.")
    parser.add_argument("--tile-size", type=int, default=1024, help="Pixel size of the sliding detection window.")
    parser.add_argument("--tile-overlap", type=float, default=0.20, help="Overlap ratio between adjacent windows.")
    return parser.parse_args()
def load_image_paths(input_dir: Path) -> tuple[list[Path], list[dict[str, str]]]:
    paths: list[Path] = []
    skipped: list[dict[str, str]] = []
    candidates = [input_dir] if input_dir.is_file() else sorted(input_dir.iterdir())
    for path in candidates:
        if not path.is_file() or path.suffix.lower() not in IMAGE_SUFFIXES:
            continue
        try:
            with Image.open(path) as image:
                image.verify()
                if image.format == "MPO":
                    skipped.append({"file": path.name, "reason": "MPO is not supported in baseline"})
                    continue
        except Exception as exc:  # pragma: no cover - depends on source files
            skipped.append({"file": path.name, "reason": f"unreadable: {exc}"})
            continue
        paths.append(path)
    return paths, skipped
def tile_starts(length: int, tile_size: int, overlap: float) -> list[int]:
    if not 0 <= overlap < 1:
        raise ValueError("tile-overlap must be between 0 and 1")
    if length <= tile_size:
        return [0]
    stride = max(1, int(tile_size * (1 - overlap)))
    starts = list(range(0, length - tile_size + 1, stride))
    last = length - tile_size
    if starts[-1] != last:
        starts.append(last)
    return starts
def intersection_over_union(box_a: list[float], box_b: list[float]) -> float:
    left = max(box_a[0], box_b[0])
    top = max(box_a[1], box_b[1])
    right = min(box_a[2], box_b[2])
    bottom = min(box_a[3], box_b[3])
    intersection = max(0.0, right - left) * max(0.0, bottom - top)
    area_a = max(0.0, box_a[2] - box_a[0]) * max(0.0, box_a[3] - box_a[1])
    area_b = max(0.0, box_b[2] - box_b[0]) * max(0.0, box_b[3] - box_b[1])
    union = area_a + area_b - intersection
    return intersection / union if union else 0.0
def intersection_over_smaller(box_a: list[float], box_b: list[float]) -> float:
    left = max(box_a[0], box_b[0])
    top = max(box_a[1], box_b[1])
    right = min(box_a[2], box_b[2])
    bottom = min(box_a[3], box_b[3])
    intersection = max(0.0, right - left) * max(0.0, bottom - top)
    area_a = max(0.0, box_a[2] - box_a[0]) * max(0.0, box_a[3] - box_a[1])
    area_b = max(0.0, box_b[2] - box_b[0]) * max(0.0, box_b[3] - box_b[1])
    smaller_area = min(area_a, area_b)
    return intersection / smaller_area if smaller_area else 0.0
def class_aware_nms(
    detections: list[dict[str, Any]],
    iou_threshold: float = 0.50,
    containment_threshold: float = 0.80,
) -> list[dict[str, Any]]:
    kept: list[dict[str, Any]] = []
    for candidate in sorted(detections, key=lambda item: item["confidence"], reverse=True):
        if all(
            candidate["class_id"] != existing["class_id"]
            or (
                intersection_over_union(candidate["bbox_xyxy"], existing["bbox_xyxy"]) < iou_threshold
                and intersection_over_smaller(candidate["bbox_xyxy"], existing["bbox_xyxy"])
                < containment_threshold
            )
            for existing in kept
        ):
            kept.append(candidate)
    return kept
def predict_tiled(model: Any, image_path: Path, args: argparse.Namespace, np: Any) -> tuple[list[dict[str, Any]], int, int]:
    with Image.open(image_path) as source:
        image = source.convert("RGB")
    width, height = image.size
    detections: list[dict[str, Any]] = []
    names: dict[int, str] = {}
    for y0 in tile_starts(height, args.tile_size, args.tile_overlap):
        for x0 in tile_starts(width, args.tile_size, args.tile_overlap):
            tile = image.crop((x0, y0, min(x0 + args.tile_size, width), min(y0 + args.tile_size, height)))
            result = model.predict(
                source=np.asarray(tile),
                device="cpu",
                imgsz=args.image_size,
                conf=args.confidence,
                classes=COCO_TARGET_CLASS_IDS,
                max_det=100,
                verbose=False,
            )[0]
            names = result.names
            if result.boxes is None:
                continue
            for box in result.boxes:
                class_id = int(box.cls.item())
                local_box = [float(value) for value in box.xyxy[0].tolist()]
                detections.append(
                    {
                        "class_id": class_id,
                        "class_name": names[class_id],
                        "confidence": round(float(box.conf.item()), 4),
                        "bbox_xyxy": [
                            round(local_box[0] + x0, 2),
                            round(local_box[1] + y0, 2),
                            round(local_box[2] + x0, 2),
                            round(local_box[3] + y0, 2),
                        ],
                    }
                )
    return class_aware_nms(detections), width, height
def draw_detections(image_path: Path, detections: list[dict[str, Any]], output_path: Path) -> None:
    import cv2
    import numpy as np
    canvas = cv2.cvtColor(np.asarray(Image.open(image_path).convert("RGB")), cv2.COLOR_RGB2BGR)
    for detection in detections:
        x1, y1, x2, y2 = [int(round(value)) for value in detection["bbox_xyxy"]]
        label = f"{detection['class_name']} {detection['confidence']:.2f}"
        cv2.rectangle(canvas, (x1, y1), (x2, y2), (255, 80, 0), 4)
        cv2.putText(canvas, label, (x1, max(30, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 80, 0), 2)
    cv2.imwrite(str(output_path), canvas)
def main() -> int:
    args = parse_args()
    input_dir = args.input.resolve()
    output_dir = args.output.resolve()
    annotated_dir = output_dir / "annotated"
    annotated_dir.mkdir(parents=True, exist_ok=True)
    cache_root = Path(tempfile.gettempdir()) / "geoai-object-detection"
    os.environ.setdefault("YOLO_CONFIG_DIR", str(cache_root / "ultralytics"))
    os.environ.setdefault("MPLCONFIGDIR", str(cache_root / "matplotlib"))
    if not input_dir.exists():
        print(f"Input directory does not exist: {input_dir}", file=sys.stderr)
        return 2
    image_paths, skipped = load_image_paths(input_dir)
    if not image_paths:
        print("No supported images found.", file=sys.stderr)
        return 2
    # Imports happen after argument validation so the CLI can explain path
    # mistakes without requiring the heavyweight ML stack.
    import geoai
    import numpy as np
    import torch
    import ultralytics
    from ultralytics import YOLO
    started = time.perf_counter()
    model = YOLO(args.model)
    detections: list[dict[str, Any]] = []
    for image_path in image_paths:
        image_detections, width, height = predict_tiled(model, image_path, args, np)
        annotated_path = annotated_dir / image_path.name
        draw_detections(image_path, image_detections, annotated_path)
        detections.append(
            {
                "file": image_path.name,
                "annotated_file": str(annotated_path.relative_to(output_dir)),
                "width": width,
                "height": height,
                "detections": image_detections,
            }
        )
    elapsed = round(time.perf_counter() - started, 3)
    (output_dir / "detections.json").write_text(
        json.dumps({"images": detections}, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    metadata = {
        "created_at": datetime.now(UTC).isoformat(),
        "geoai_package": getattr(geoai, "__version__", "unknown"),
        "ultralytics": ultralytics.__version__,
        "torch": torch.__version__,
        "device": "cpu",
        "cuda_available": bool(torch.cuda.is_available()),
        "model": args.model,
        "confidence": args.confidence,
        "image_size": args.image_size,
        "tile_size": args.tile_size,
        "tile_overlap": args.tile_overlap,
        "merge_iou_threshold": 0.50,
        "merge_containment_threshold": 0.80,
        "target_class_ids": COCO_TARGET_CLASS_IDS,
        "target_class_note": "COCO person, bicycle, car, motorcycle, bus and truck; tree is not a COCO class.",
        "input_dir": str(input_dir),
        "processed_images": len(image_paths),
        "skipped_images": skipped,
        "detection_count": sum(len(item["detections"]) for item in detections),
        "elapsed_seconds": elapsed,
        "notes": [
            "This is a baseline for people and common vehicle classes.",
            "The default COCO model does not provide a tree class.",
            "JPEG inputs have no usable GPS metadata; GeoJSON is deferred to GeoTIFF stage.",
        ],
    }
    (output_dir / "run_metadata.json").write_text(
        json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(f"Processed {len(image_paths)} images; detections={metadata['detection_count']}; elapsed={elapsed}s")
    print(f"Annotated images: {annotated_dir}")
    print(f"JSON: {output_dir / 'detections.json'}")
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
capabilities/01-object-detection/run_geoai_vehicle_detection.py
New file
@@ -0,0 +1,220 @@
"""Detect aerial vehicles with the pretrained model bundled by geoai-py."""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import time
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from PIL import Image
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
VEHICLE_CLASS_ID = 10
CONTAINMENT_THRESHOLD = 0.80
def parse_args() -> argparse.Namespace:
    root = Path(__file__).resolve().parents[2]
    parser = argparse.ArgumentParser(description="Detect vehicles in aerial images with geoai-py.")
    parser.add_argument(
        "--input",
        type=Path,
        default=root / "shared" / "data" / "raw" / "01-object-detection",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=root / "shared" / "outputs" / "01-object-detection" / "geoai-vehicles",
    )
    parser.add_argument("--confidence", type=float, default=0.30)
    parser.add_argument("--window-size", type=int, default=512)
    parser.add_argument("--overlap", type=int, default=128)
    parser.add_argument("--nms-threshold", type=float, default=0.30)
    parser.add_argument("--batch-size", type=int, default=4)
    return parser.parse_args()
def load_image_paths(input_path: Path) -> tuple[list[Path], list[dict[str, str]]]:
    candidates = [input_path] if input_path.is_file() else sorted(input_path.iterdir())
    paths: list[Path] = []
    skipped: list[dict[str, str]] = []
    for path in candidates:
        if not path.is_file() or path.suffix.lower() not in IMAGE_SUFFIXES:
            continue
        try:
            with Image.open(path) as image:
                image.verify()
                if image.format == "MPO":
                    skipped.append({"file": path.name, "reason": "MPO is not supported"})
                    continue
        except Exception as exc:
            skipped.append({"file": path.name, "reason": f"unreadable: {exc}"})
            continue
        paths.append(path)
    return paths, skipped
def intersection_over_smaller(box_a: list[float], box_b: list[float]) -> float:
    left = max(box_a[0], box_b[0])
    top = max(box_a[1], box_b[1])
    right = min(box_a[2], box_b[2])
    bottom = min(box_a[3], box_b[3])
    intersection = max(0.0, right - left) * max(0.0, bottom - top)
    area_a = max(0.0, box_a[2] - box_a[0]) * max(0.0, box_a[3] - box_a[1])
    area_b = max(0.0, box_b[2] - box_b[0]) * max(0.0, box_b[3] - box_b[1])
    smaller_area = min(area_a, area_b)
    return intersection / smaller_area if smaller_area else 0.0
def serializable_vehicle_detections(detections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    vehicles: list[dict[str, Any]] = []
    for detection in sorted(detections, key=lambda item: float(item["score"]), reverse=True):
        if int(detection["label"]) != VEHICLE_CLASS_ID:
            continue
        box = [round(float(value), 2) for value in detection["box"]]
        if any(
            intersection_over_smaller(box, existing["bbox_xyxy"]) >= CONTAINMENT_THRESHOLD
            for existing in vehicles
        ):
            continue
        vehicles.append(
            {
                "class_id": VEHICLE_CLASS_ID,
                "class_name": "vehicle",
                "confidence": round(float(detection["score"]), 4),
                "bbox_xyxy": box,
            }
        )
    return vehicles
def draw_detections(image_path: Path, detections: list[dict[str, Any]], output_path: Path) -> None:
    import cv2
    import numpy as np
    canvas = cv2.cvtColor(np.asarray(Image.open(image_path).convert("RGB")), cv2.COLOR_RGB2BGR)
    for detection in detections:
        x1, y1, x2, y2 = [int(round(value)) for value in detection["bbox_xyxy"]]
        label = f"vehicle {detection['confidence']:.2f}"
        cv2.rectangle(canvas, (x1, y1), (x2, y2), (0, 140, 255), 4)
        cv2.putText(
            canvas,
            label,
            (x1, max(30, y1 - 8)),
            cv2.FONT_HERSHEY_SIMPLEX,
            1.0,
            (0, 140, 255),
            2,
        )
    cv2.imwrite(str(output_path), canvas)
def main() -> int:
    args = parse_args()
    input_path = args.input.resolve()
    output_dir = args.output.resolve()
    annotated_dir = output_dir / "annotated"
    raster_dir = output_dir / "rasters"
    annotated_dir.mkdir(parents=True, exist_ok=True)
    raster_dir.mkdir(parents=True, exist_ok=True)
    cache_root = Path(tempfile.gettempdir()) / "geoai-object-detection"
    os.environ.setdefault("MPLCONFIGDIR", str(cache_root / "matplotlib"))
    if not input_path.exists():
        print(f"Input does not exist: {input_path}", file=sys.stderr)
        return 2
    image_paths, skipped = load_image_paths(input_path)
    if not image_paths:
        print("No supported images found.", file=sys.stderr)
        return 2
    import geoai
    import torch
    from geoai.object_detect import NWPU_VHR10_CLASSES, multiclass_detection
    started = time.perf_counter()
    results: list[dict[str, Any]] = []
    for image_path in image_paths:
        raster_path = raster_dir / f"{image_path.stem}_instances.tif"
        _, inference_seconds, raw_detections = multiclass_detection(
            input_path=str(image_path),
            output_path=str(raster_path),
            window_size=args.window_size,
            overlap=args.overlap,
            confidence_threshold=args.confidence,
            nms_threshold=args.nms_threshold,
            batch_size=args.batch_size,
            device=torch.device("cpu"),
        )
        vehicles = serializable_vehicle_detections(raw_detections)
        annotated_path = annotated_dir / f"{image_path.stem}.jpg"
        draw_detections(image_path, vehicles, annotated_path)
        with Image.open(image_path) as image:
            width, height = image.size
        raw_counts = Counter(NWPU_VHR10_CLASSES[int(item["label"])] for item in raw_detections)
        results.append(
            {
                "file": image_path.name,
                "width": width,
                "height": height,
                "annotated_file": str(annotated_path.relative_to(output_dir)),
                "instance_raster": str(raster_path.relative_to(output_dir)),
                "inference_seconds": round(float(inference_seconds), 3),
                "raw_class_counts": dict(raw_counts),
                "detections": vehicles,
            }
        )
        del raw_detections
    elapsed = round(time.perf_counter() - started, 3)
    (output_dir / "detections.json").write_text(
        json.dumps({"images": results}, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    metadata = {
        "created_at": datetime.now(UTC).isoformat(),
        "geoai_package": getattr(geoai, "__version__", "unknown"),
        "device": "cpu",
        "model": "giswqs/nwpu-vhr10-maskrcnn:best_model.pth",
        "model_architecture": "Mask R-CNN ResNet-50 FPN",
        "source_classes": NWPU_VHR10_CLASSES,
        "kept_classes": ["vehicle"],
        "confidence": args.confidence,
        "window_size": args.window_size,
        "overlap": args.overlap,
        "nms_threshold": args.nms_threshold,
        "containment_threshold": CONTAINMENT_THRESHOLD,
        "batch_size": args.batch_size,
        "processed_images": len(results),
        "skipped_images": skipped,
        "vehicle_count": sum(len(item["detections"]) for item in results),
        "elapsed_seconds": elapsed,
        "notes": [
            "The NWPU-VHR10 vehicle class combines vehicle subtypes.",
            "The model does not contain person or tree classes.",
            "JPEG detections use pixel coordinates, not geographic coordinates.",
        ],
    }
    (output_dir / "run_metadata.json").write_text(
        json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(
        f"Processed {len(results)} images; vehicles={metadata['vehicle_count']}; "
        f"elapsed={elapsed}s"
    )
    print(f"Annotated images: {annotated_dir}")
    print(f"JSON: {output_dir / 'detections.json'}")
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
capabilities/15-trajectory-analysis/README.md
@@ -1,6 +1,120 @@
# Trajectory Analysis
# 轨迹分析与行为识别
- 输入:带时间戳的无人机、车辆、人员或船舶轨迹。
- 输出:偏航、越界、聚集、停留和轨迹类别。
- 首个 Demo:规则检测加轨迹聚类。
状态:首个 CPU Demo 已验证(2026-08-14)。
## 能力边界
当前 Demo 归为 **C(产品业务能力)**,不是 `geoai-py` 内置功能:
- 产品实现用 GeoPandas、Shapely、PyProj 和 scikit-learn 完成轨迹度量、空间关系计算、DBSCAN 分组和规则判断。仅使用这些生态库、但没有实际调用 `geoai-py`,不能标为 B。
- `geoai-py` 的准确角色是上游来源之一:其目标检测和地理结果导出可为跟踪系统提供观测,但 `geoai-py 0.42.0` 没有轨迹、目标跟踪或行为识别 API。本 Demo 运行时因此不安装或调用 `geoai-py`。
未来若把 `geoai-py` 检测/导出、独立多目标跟踪器和本规则服务连成完整流水线,流水线可同时包含 A、B、C;当前已实现并验证的部分仍是 C。
首版所称“行为识别”是可解释的时空规则,不是视频动作识别模型,也不负责把逐帧检测框关联成轨迹 ID。
## Demo 契约
输入是一个 `*.case.json` 清单,引用三类真实业务可导出的文件:
1. 轨迹点 CSV:固定字段 `track_id,entity_type,timestamp,longitude,latitude`。
2. 参考路线 GeoJSON:每条 `LineString` 带 `track_id`。
3. 区域 GeoJSON:每个面带 `zone_id` 和 `zone_type`;首版识别 `restricted`。
首版只接受带有效 CRS 的 WGS84 经纬度(`EPSG:4326`)。每条轨迹至少需要两个不同时间戳;同一轨迹的重复时间戳保留最后一条并在元数据中计数。
仓库没有用户实测轨迹,因此 [generate_demo_inputs.py](./generate_demo_inputs.py) 会生成两组小型、可审计的合成代表样本:
- `normal`:2 条连续移动且贴合参考路线的轨迹,预期无事件。
- `difficult`:3 条乱序轨迹,含 1 条重复观测,以及可人工核对的停留、偏航、禁入区和双人聚集。
生成数据位于 `shared/data/raw/15-trajectory-analysis/`,不会提交 Git。真实验证仍需用户提供同结构的脱敏轨迹。
## 环境与运行
```powershell
# Python 3.12 隔离环境
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\setup.ps1 -Capability 15-trajectory-analysis
$py = '.\.venvs\15-trajectory-analysis\Scripts\python.exe'
# 第一次生成验收输入
& $py .\capabilities\15-trajectory-analysis\generate_demo_inputs.py
# 分析目录内的 normal.case.json 和 difficult.case.json
& $py .\capabilities\15-trajectory-analysis\run_trajectory_analysis.py
# 只分析一个用户清单;输出仍放在 <output>/<case_id>/
& $py .\capabilities\15-trajectory-analysis\run_trajectory_analysis.py `
  --input .\path\to\my.case.json `
  --output .\shared\outputs\15-trajectory-analysis\my-run
```
脚本默认拒绝覆盖已有输入和结果。只有明确要替换此前运行时才传 `--overwrite`。
## 规则与输出
默认规则:
| 事件 | 条件 |
| --- | --- |
| 停留 `stop` | 速度不高于 0.8 m/s,持续至少 60 秒 |
| 偏航 `route_deviation` | 到参考路线距离大于 25 m,持续至少 20 秒 |
| 禁入区 `restricted_zone` | 轨迹点落入 `restricted` 面 |
| 聚集 `gathering` | 至少 2 条轨迹在 20 m 内,持续至少 30 秒 |
清单可用 `thresholds` 对象覆盖上述阈值。相邻观测超过 60 秒时事件连续段会断开,避免把长时间数据缺失误算为持续行为。
每个案例固定输出:
| 文件 | 内容 |
| --- | --- |
| `trajectory_summary.csv` | 每条轨迹的时间、距离、速度、偏航比例、停留/禁入时长、聚类 ID 和行为标签 |
| `events.json` | 固定事件字段、参与轨迹、起止时间、位置和规则细节 |
| `trajectories.geojson` | WGS84 轨迹线及汇总属性 |
| `events.geojson` | WGS84 事件点及事件属性 |
| `analysis.png` | 轨迹、参考路线、区域和不同形状事件标记 |
| `run_metadata.json` | Python/依赖版本、规则/模型、阈值、CPU、输入数、耗时、清洗数和限制 |
DBSCAN 只对轨迹汇总特征做探索性分组;`cluster_id=-1` 表示没有足够相似轨迹形成簇,不代表异常已被确认。
## 已测验收结果
运行环境:Python 3.12.10、CPU;`pip check` 通过。目录级运行处理 2 个案例、5 条轨迹和 78 条去重后观测,总耗时约 2.8 秒。
| 验收项 | 预期 | 实测 |
| --- | --- | --- |
| 正常样本 | 2 条轨迹,0 个事件 | 通过:2 条轨迹,0 个事件 |
| 困难样本清洗 | 乱序可排序,删除 1 条重复时间观测 | 通过:删除 1 条 |
| 困难样本行为 | 四类事件各至少 1 个 | 通过:停留、偏航、禁入区、聚集各 1 个 |
| 失败处理 | 缺少必填 CSV 字段时退出码为 2,并指出字段 | 通过 |
| 输出完整性 | 每个案例生成 6 个非空文件 | 通过 |
| 视觉检查 | 正常/困难 PNG 非空,轨迹、区域和事件可区分 | 通过;两张图均为 1400 x 980 |
自动验收:
```powershell
$py = '.\.venvs\15-trajectory-analysis\Scripts\python.exe'
& $py -m unittest discover -s .\capabilities\15-trajectory-analysis\tests -v
```
## 许可证
| 项目 | 当前记录 |
| --- | --- |
| `geoai-py 0.42.0` | MIT;仅为可选上游,本 Demo 环境未安装 |
| pandas / GeoPandas / Shapely / scikit-learn | BSD 3-Clause 系列许可证 |
| PyProj | MIT;同时需遵守其随附 PROJ 数据/组件许可 |
| Matplotlib | Matplotlib License;随附字体等资产有各自许可证 |
| Demo 数据 | 由本仓库脚本生成,无第三方数据集或模型权重 |
| 预训练模型/权重 | 无 |
以上是开发阶段记录,不等于已完成产品商用法务审查。
## 已知限制与下一步
- 合成样本只能验证程序逻辑,不能证明真实场景准确率;目前没有可报告的真实误报率和漏报率。
- 聚集要求时间戳对齐,GPS 漂移、采样频率和轨迹断点会直接影响结果。
- 偏航依赖可信参考路线;禁入区依赖有效区域数据;不同人员、车辆、船舶和无人机需要分别标定阈值。
- 下一步应先选 1 个正常、1 个困难的脱敏实测轨迹,人工标注事件,再评估误报、漏报和阈值,而不是直接跑大目录。
- 若输入来自无人机视频,还需在此能力之前引入独立的多目标跟踪器,并验证 ID 切换问题。
capabilities/15-trajectory-analysis/generate_demo_inputs.py
New file
@@ -0,0 +1,197 @@
"""Generate small, auditable trajectory-analysis demo inputs."""
from __future__ import annotations
import argparse
import csv
import json
import math
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
def parse_args() -> argparse.Namespace:
    root = Path(__file__).resolve().parents[2]
    parser = argparse.ArgumentParser(description="Generate trajectory demo inputs.")
    parser.add_argument(
        "--output",
        type=Path,
        default=root / "shared" / "data" / "raw" / "15-trajectory-analysis",
    )
    parser.add_argument("--overwrite", action="store_true")
    return parser.parse_args()
def feature_collection(features: list[dict[str, Any]]) -> dict[str, Any]:
    return {"type": "FeatureCollection", "features": features}
def line_feature(track_id: str, coordinates: list[list[float]]) -> dict[str, Any]:
    return {
        "type": "Feature",
        "properties": {"track_id": track_id},
        "geometry": {"type": "LineString", "coordinates": coordinates},
    }
def write_json(path: Path, payload: dict[str, Any]) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def write_case(
    output_dir: Path,
    case_id: str,
    description: str,
    rows: list[dict[str, str]],
    routes: list[dict[str, Any]],
    zones: list[dict[str, Any]],
) -> None:
    csv_name = f"{case_id}_observations.csv"
    routes_name = f"{case_id}_routes.geojson"
    zones_name = f"{case_id}_zones.geojson"
    with (output_dir / csv_name).open("w", newline="", encoding="utf-8") as stream:
        writer = csv.DictWriter(
            stream,
            fieldnames=["track_id", "entity_type", "timestamp", "longitude", "latitude"],
        )
        writer.writeheader()
        writer.writerows(rows)
    write_json(output_dir / routes_name, feature_collection(routes))
    write_json(output_dir / zones_name, feature_collection(zones))
    write_json(
        output_dir / f"{case_id}.case.json",
        {
            "case_id": case_id,
            "description": description,
            "crs": "EPSG:4326",
            "observations": csv_name,
            "reference_routes": routes_name,
            "zones": zones_name,
        },
    )
def observation(
    track_id: str,
    entity_type: str,
    timestamp: datetime,
    longitude: float,
    latitude: float,
) -> dict[str, str]:
    return {
        "track_id": track_id,
        "entity_type": entity_type,
        "timestamp": timestamp.isoformat().replace("+00:00", "Z"),
        "longitude": f"{longitude:.7f}",
        "latitude": f"{latitude:.7f}",
    }
def build_normal_case() -> tuple[list[dict[str, str]], list[dict[str, Any]]]:
    started = datetime(2026, 8, 14, 1, 0, tzinfo=UTC)
    rows: list[dict[str, str]] = []
    routes: list[dict[str, Any]] = []
    for track_id, entity_type, base_lat in (
        ("drone-normal-01", "drone", 31.2000),
        ("vehicle-normal-01", "vehicle", 31.1995),
    ):
        coordinates: list[list[float]] = []
        for index in range(16):
            lon = 121.4700 + index * 0.00012
            lat = base_lat + math.sin(index / 2) * 0.000006
            coordinates.append([lon, base_lat])
            rows.append(observation(track_id, entity_type, started + timedelta(seconds=10 * index), lon, lat))
        routes.append(line_feature(track_id, coordinates))
    return rows, routes
def build_difficult_case() -> tuple[list[dict[str, str]], list[dict[str, Any]]]:
    started = datetime(2026, 8, 14, 2, 0, tzinfo=UTC)
    rows: list[dict[str, str]] = []
    routes: list[dict[str, Any]] = []
    car_route = [[121.4700 + index * 0.00010, 31.2000] for index in range(20)]
    routes.append(line_feature("vehicle-difficult-01", car_route))
    for index in range(20):
        if index <= 5:
            lon, lat = 121.4700 + index * 0.00010, 31.2000
        elif index <= 13:
            lon, lat = 121.4705, 31.2000
        else:
            lon, lat = 121.4705 + (index - 13) * 0.00010, 31.20055
        rows.append(
            observation(
                "vehicle-difficult-01", "vehicle", started + timedelta(seconds=10 * index), lon, lat
            )
        )
    # One duplicate is intentional: the analyzer must deduplicate it and report the cleanup.
    rows.append(observation("vehicle-difficult-01", "vehicle", started + timedelta(seconds=100), 121.4705, 31.2000))
    for track_id, base_lat, offset in (
        ("person-group-01", 31.19935, -0.000035),
        ("person-group-02", 31.19965, 0.000035),
    ):
        coordinates: list[list[float]] = []
        for index in range(13):
            lon = 121.4700 + index * 0.00011
            if 4 <= index <= 9:
                lat = 31.19950 + offset
            else:
                lat = base_lat
            coordinates.append([lon, lat])
            rows.append(observation(track_id, "person", started + timedelta(seconds=10 * index), lon, lat))
        routes.append(line_feature(track_id, coordinates))
    # Deliberately reverse the rows so sorting is exercised on the difficult case.
    rows.reverse()
    return rows, routes
def main() -> int:
    args = parse_args()
    output_dir = args.output.resolve()
    expected = output_dir / "normal.case.json"
    if expected.exists() and not args.overwrite:
        print(f"Demo inputs already exist: {output_dir}. Use --overwrite to replace them.", file=sys.stderr)
        return 2
    output_dir.mkdir(parents=True, exist_ok=True)
    zone = {
        "type": "Feature",
        "properties": {"zone_id": "restricted-01", "zone_type": "restricted"},
        "geometry": {
            "type": "Polygon",
            "coordinates": [[
                [121.47035, 31.20030],
                [121.47125, 31.20030],
                [121.47125, 31.20080],
                [121.47035, 31.20080],
                [121.47035, 31.20030],
            ]],
        },
    }
    normal_rows, normal_routes = build_normal_case()
    write_case(
        output_dir,
        "normal",
        "Two continuously moving tracks following their routes without rule events.",
        normal_rows,
        normal_routes,
        [zone],
    )
    difficult_rows, difficult_routes = build_difficult_case()
    write_case(
        output_dir,
        "difficult",
        "Unsorted observations with a duplicate, a stop, route deviation, restricted-zone entry and gathering.",
        difficult_rows,
        difficult_routes,
        [zone],
    )
    print(f"Generated normal and difficult demo inputs in {output_dir}")
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
capabilities/15-trajectory-analysis/requirements.txt
@@ -1,5 +1,4 @@
-r ../../requirements/base.txt
geopandas>=1,<2
scikit-learn>=1.5,<2
movingpandas>=0.19,<1
matplotlib>=3.9,<4
capabilities/15-trajectory-analysis/run_trajectory_analysis.py
New file
@@ -0,0 +1,636 @@
"""Analyze timestamped WGS84 trajectories with clustering and explicit rules."""
from __future__ import annotations
import argparse
import json
import platform
import sys
import time
from collections import defaultdict
from datetime import UTC, datetime
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any, Iterable
import geopandas as gpd
import matplotlib
import numpy as np
import pandas as pd
from pyproj import CRS
from shapely.geometry import LineString, Point, mapping
from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
DEFAULT_THRESHOLDS = {
    "stop_speed_mps": 0.8,
    "stop_duration_seconds": 60.0,
    "route_deviation_m": 25.0,
    "route_deviation_duration_seconds": 20.0,
    "gathering_radius_m": 20.0,
    "gathering_duration_seconds": 30.0,
    "max_observation_gap_seconds": 60.0,
}
REQUIRED_COLUMNS = {"track_id", "entity_type", "timestamp", "longitude", "latitude"}
EVENT_COLORS = {
    "stop": "#d55e00",
    "route_deviation": "#cc79a7",
    "restricted_zone": "#e69f00",
    "gathering": "#009e73",
}
EVENT_MARKERS = {
    "stop": "s",
    "route_deviation": "^",
    "restricted_zone": "D",
    "gathering": "P",
}
EVENT_SIZES = {
    "stop": 62,
    "route_deviation": 42,
    "restricted_zone": 92,
    "gathering": 68,
}
def parse_args() -> argparse.Namespace:
    root = Path(__file__).resolve().parents[2]
    parser = argparse.ArgumentParser(description="Analyze timestamped WGS84 trajectories.")
    parser.add_argument(
        "--input",
        type=Path,
        default=root / "shared" / "data" / "raw" / "15-trajectory-analysis",
        help="A .case.json manifest or a directory containing manifests.",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=root / "shared" / "outputs" / "15-trajectory-analysis",
    )
    parser.add_argument("--overwrite", action="store_true", help="Replace existing case outputs.")
    return parser.parse_args()
def package_version(name: str) -> str:
    try:
        return version(name)
    except PackageNotFoundError:
        return "not-installed"
def utc_text(value: pd.Timestamp | datetime) -> str:
    return value.to_pydatetime().astimezone(UTC).isoformat() if isinstance(value, pd.Timestamp) else value.astimezone(UTC).isoformat()
def load_json(path: Path) -> dict[str, Any]:
    with path.open(encoding="utf-8") as stream:
        payload = json.load(stream)
    if not isinstance(payload, dict):
        raise ValueError(f"JSON root must be an object: {path}")
    return payload
def resolve_input_path(manifest_path: Path, value: str) -> Path:
    path = Path(value)
    return path.resolve() if path.is_absolute() else (manifest_path.parent / path).resolve()
def choose_metric_crs(longitude: float, latitude: float) -> CRS:
    zone = int((longitude + 180) // 6) + 1
    epsg = (32600 if latitude >= 0 else 32700) + zone
    return CRS.from_epsg(epsg)
def load_case(manifest_path: Path) -> tuple[dict[str, Any], pd.DataFrame, gpd.GeoDataFrame, gpd.GeoDataFrame, int, CRS]:
    manifest = load_json(manifest_path)
    for field in ("case_id", "crs", "observations", "reference_routes", "zones"):
        if not manifest.get(field):
            raise ValueError(f"Manifest is missing '{field}': {manifest_path}")
    if manifest["crs"] != "EPSG:4326":
        raise ValueError("Demo v1 accepts only EPSG:4326 longitude/latitude observations.")
    observations_path = resolve_input_path(manifest_path, manifest["observations"])
    routes_path = resolve_input_path(manifest_path, manifest["reference_routes"])
    zones_path = resolve_input_path(manifest_path, manifest["zones"])
    frame = pd.read_csv(observations_path)
    missing = REQUIRED_COLUMNS - set(frame.columns)
    if missing:
        raise ValueError(f"Observation CSV is missing columns: {', '.join(sorted(missing))}")
    raw_count = len(frame)
    if raw_count == 0:
        raise ValueError("Observation CSV is empty.")
    frame["track_id"] = frame["track_id"].astype(str).str.strip()
    frame["entity_type"] = frame["entity_type"].astype(str).str.strip()
    if (frame["track_id"] == "").any() or (frame["entity_type"] == "").any():
        raise ValueError("track_id and entity_type cannot be empty.")
    frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True, errors="raise")
    frame["longitude"] = pd.to_numeric(frame["longitude"], errors="raise")
    frame["latitude"] = pd.to_numeric(frame["latitude"], errors="raise")
    valid = frame["longitude"].between(-180, 180) & frame["latitude"].between(-90, 90)
    if not valid.all():
        raise ValueError("longitude/latitude contains values outside WGS84 bounds.")
    frame = (
        frame.sort_values(["track_id", "timestamp"])
        .drop_duplicates(["track_id", "timestamp"], keep="last")
        .reset_index(drop=True)
    )
    duplicate_count = raw_count - len(frame)
    if frame.groupby("track_id").size().min() < 2:
        raise ValueError("Every track must contain at least two distinct timestamps.")
    routes = gpd.read_file(routes_path)
    zones = gpd.read_file(zones_path)
    if "track_id" not in routes.columns:
        raise ValueError("Reference route GeoJSON must contain a track_id property.")
    if "zone_id" not in zones.columns or "zone_type" not in zones.columns:
        raise ValueError("Zone GeoJSON must contain zone_id and zone_type properties.")
    if routes.crs is None or zones.crs is None:
        raise ValueError("Route and zone GeoJSON files must declare a CRS.")
    expected = set(frame["track_id"])
    missing_routes = expected - set(routes["track_id"].astype(str))
    if missing_routes:
        raise ValueError(f"Missing reference routes for: {', '.join(sorted(missing_routes))}")
    metric_crs = choose_metric_crs(float(frame["longitude"].mean()), float(frame["latitude"].mean()))
    return manifest, frame, routes.to_crs(metric_crs), zones.to_crs(metric_crs), duplicate_count, metric_crs
def true_runs(flags: list[bool], times: list[pd.Timestamp], max_gap: float) -> Iterable[tuple[int, int]]:
    start: int | None = None
    for index, flag in enumerate(flags):
        separated = index > 0 and (times[index] - times[index - 1]).total_seconds() > max_gap
        if flag and (start is None or separated):
            if start is not None:
                yield start, index - 1
            start = index
        elif not flag and start is not None:
            yield start, index - 1
            start = None
    if start is not None:
        yield start, len(flags) - 1
def make_event(
    case_id: str,
    event_type: str,
    track_ids: list[str],
    started: pd.Timestamp,
    ended: pd.Timestamp,
    point: Point,
    details: dict[str, Any],
) -> dict[str, Any]:
    duration = max(0.0, (ended - started).total_seconds())
    return {
        "event_id": "",
        "case_id": case_id,
        "event_type": event_type,
        "track_ids": track_ids,
        "start_time": utc_text(started),
        "end_time": utc_text(ended),
        "duration_seconds": round(duration, 3),
        "longitude": round(point.x, 7),
        "latitude": round(point.y, 7),
        "details": details,
    }
def analyze_tracks(
    case_id: str,
    frame: pd.DataFrame,
    routes_metric: gpd.GeoDataFrame,
    zones_metric: gpd.GeoDataFrame,
    metric_crs: CRS,
    thresholds: dict[str, float],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, gpd.GeoDataFrame]]:
    points = gpd.GeoDataFrame(
        frame.copy(),
        geometry=gpd.points_from_xy(frame["longitude"], frame["latitude"]),
        crs="EPSG:4326",
    )
    points_metric = points.to_crs(metric_crs)
    route_map = {str(row.track_id): row.geometry for row in routes_metric.itertuples()}
    restricted = zones_metric[zones_metric["zone_type"].astype(str) == "restricted"]
    events: list[dict[str, Any]] = []
    summaries: list[dict[str, Any]] = []
    track_frames: dict[str, gpd.GeoDataFrame] = {}
    for track_id, group_indexes in points_metric.groupby("track_id", sort=True).groups.items():
        track = points_metric.loc[group_indexes].sort_values("timestamp").copy().reset_index(drop=True)
        track_wgs84 = track.to_crs("EPSG:4326")
        track_frames[str(track_id)] = track_wgs84
        times = list(track["timestamp"])
        step_dt = track["timestamp"].diff().dt.total_seconds().fillna(0.0).to_numpy()
        step_distance = np.zeros(len(track), dtype=float)
        for index in range(1, len(track)):
            step_distance[index] = track.geometry.iloc[index - 1].distance(track.geometry.iloc[index])
        step_speed = np.divide(step_distance, step_dt, out=np.zeros_like(step_distance), where=step_dt > 0)
        max_gap = thresholds["max_observation_gap_seconds"]
        slow_intervals = [
            index > 0 and step_dt[index] <= max_gap and step_speed[index] <= thresholds["stop_speed_mps"]
            for index in range(len(track))
        ]
        stop_seconds = 0.0
        for start, end in true_runs(slow_intervals, times, max_gap):
            event_start = max(0, start - 1)
            duration = (times[end] - times[event_start]).total_seconds()
            if duration >= thresholds["stop_duration_seconds"]:
                stop_seconds += duration
                point = track_wgs84.geometry.iloc[event_start : end + 1].union_all().centroid
                events.append(
                    make_event(
                        case_id,
                        "stop",
                        [str(track_id)],
                        times[event_start],
                        times[end],
                        point,
                        {"max_speed_mps": round(float(step_speed[start : end + 1].max()), 3)},
                    )
                )
        route = route_map[str(track_id)]
        route_distances = np.array([geometry.distance(route) for geometry in track.geometry], dtype=float)
        deviated = list(route_distances > thresholds["route_deviation_m"])
        deviation_seconds = 0.0
        for start, end in true_runs(deviated, times, max_gap):
            duration = (times[end] - times[start]).total_seconds()
            if duration >= thresholds["route_deviation_duration_seconds"]:
                deviation_seconds += duration
                events.append(
                    make_event(
                        case_id,
                        "route_deviation",
                        [str(track_id)],
                        times[start],
                        times[end],
                        track_wgs84.geometry.iloc[end],
                        {"max_distance_m": round(float(route_distances[start : end + 1].max()), 3)},
                    )
                )
        restricted_seconds = 0.0
        inside_by_zone: dict[str, list[bool]] = {}
        for zone in restricted.itertuples():
            flags = [bool(zone.geometry.covers(geometry)) for geometry in track.geometry]
            inside_by_zone[str(zone.zone_id)] = flags
            for start, end in true_runs(flags, times, max_gap):
                duration = (times[end] - times[start]).total_seconds()
                restricted_seconds += duration
                events.append(
                    make_event(
                        case_id,
                        "restricted_zone",
                        [str(track_id)],
                        times[start],
                        times[end],
                        track_wgs84.geometry.iloc[end],
                        {"zone_id": str(zone.zone_id)},
                    )
                )
        duration_seconds = (times[-1] - times[0]).total_seconds()
        summaries.append(
            {
                "case_id": case_id,
                "track_id": str(track_id),
                "entity_type": str(track["entity_type"].iloc[0]),
                "point_count": len(track),
                "start_time": utc_text(times[0]),
                "end_time": utc_text(times[-1]),
                "duration_seconds": round(duration_seconds, 3),
                "distance_m": round(float(step_distance.sum()), 3),
                "average_speed_mps": round(float(step_distance.sum() / duration_seconds), 3),
                "max_speed_mps": round(float(step_speed.max()), 3),
                "route_deviation_ratio": round(float(np.mean(deviated)), 4),
                "restricted_zone_seconds": round(restricted_seconds, 3),
                "stop_seconds": round(stop_seconds, 3),
                "cluster_id": -1,
                "behavior_labels": "",
            }
        )
    gathering_events = detect_gathering(case_id, points_metric, thresholds)
    events.extend(gathering_events)
    labels_by_track: dict[str, set[str]] = defaultdict(set)
    for event in events:
        for track_id in event["track_ids"]:
            labels_by_track[track_id].add(event["event_type"])
    if len(summaries) >= 2:
        features = np.array(
            [
                [
                    item["distance_m"],
                    item["duration_seconds"],
                    item["average_speed_mps"],
                    item["route_deviation_ratio"],
                    item["stop_seconds"] / max(item["duration_seconds"], 1),
                ]
                for item in summaries
            ]
        )
        labels = DBSCAN(eps=1.35, min_samples=2).fit_predict(StandardScaler().fit_transform(features))
        for item, label in zip(summaries, labels, strict=True):
            item["cluster_id"] = int(label)
    for item in summaries:
        item["behavior_labels"] = "|".join(sorted(labels_by_track[item["track_id"]])) or "normal"
    events.sort(key=lambda item: (item["start_time"], item["event_type"], item["track_ids"]))
    for index, event in enumerate(events, start=1):
        event["event_id"] = f"{case_id}-event-{index:03d}"
    return summaries, events, track_frames
def detect_gathering(
    case_id: str, points_metric: gpd.GeoDataFrame, thresholds: dict[str, float]
) -> list[dict[str, Any]]:
    occurrences: dict[tuple[str, ...], list[tuple[pd.Timestamp, Point]]] = defaultdict(list)
    radius = thresholds["gathering_radius_m"]
    for timestamp, group in points_metric.groupby("timestamp"):
        rows = list(group.itertuples())
        adjacency: dict[str, set[str]] = {str(row.track_id): set() for row in rows}
        geometries = {str(row.track_id): row.geometry for row in rows}
        for left_index, left in enumerate(rows):
            for right in rows[left_index + 1 :]:
                if left.geometry.distance(right.geometry) <= radius:
                    adjacency[str(left.track_id)].add(str(right.track_id))
                    adjacency[str(right.track_id)].add(str(left.track_id))
        remaining = set(adjacency)
        while remaining:
            seed = remaining.pop()
            component = {seed}
            queue = [seed]
            while queue:
                current = queue.pop()
                neighbors = adjacency[current] & remaining
                remaining -= neighbors
                component |= neighbors
                queue.extend(neighbors)
            if len(component) >= 2:
                members = tuple(sorted(component))
                centroid = gpd.GeoSeries([geometries[item] for item in members], crs=points_metric.crs).union_all().centroid
                occurrences[members].append((timestamp, centroid))
    events: list[dict[str, Any]] = []
    max_gap = thresholds["max_observation_gap_seconds"]
    for members, values in occurrences.items():
        values.sort(key=lambda item: item[0])
        groups: list[list[tuple[pd.Timestamp, Point]]] = [[values[0]]]
        for value in values[1:]:
            if (value[0] - groups[-1][-1][0]).total_seconds() <= max_gap:
                groups[-1].append(value)
            else:
                groups.append([value])
        for run in groups:
            duration = (run[-1][0] - run[0][0]).total_seconds()
            if duration < thresholds["gathering_duration_seconds"]:
                continue
            wgs84_point = gpd.GeoSeries([run[-1][1]], crs=points_metric.crs).to_crs("EPSG:4326").iloc[0]
            events.append(
                make_event(
                    case_id,
                    "gathering",
                    list(members),
                    run[0][0],
                    run[-1][0],
                    wgs84_point,
                    {"member_count": len(members), "radius_m": radius},
                )
            )
    return events
def write_outputs(
    output_dir: Path,
    manifest: dict[str, Any],
    summaries: list[dict[str, Any]],
    events: list[dict[str, Any]],
    tracks: dict[str, gpd.GeoDataFrame],
    routes_wgs84: gpd.GeoDataFrame,
    zones_wgs84: gpd.GeoDataFrame,
    metadata: dict[str, Any],
) -> None:
    output_dir.mkdir(parents=True, exist_ok=True)
    pd.DataFrame(summaries).to_csv(output_dir / "trajectory_summary.csv", index=False, encoding="utf-8-sig")
    (output_dir / "events.json").write_text(
        json.dumps({"case_id": manifest["case_id"], "events": events}, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    trajectory_features = []
    summary_map = {item["track_id"]: item for item in summaries}
    for track_id, track in tracks.items():
        properties = dict(summary_map[track_id])
        trajectory_features.append(
            {"type": "Feature", "properties": properties, "geometry": mapping(LineString(track.geometry.tolist()))}
        )
    write_geojson(output_dir / "trajectories.geojson", trajectory_features)
    event_features = [
        {
            "type": "Feature",
            "properties": {key: value for key, value in event.items() if key not in {"longitude", "latitude"}},
            "geometry": {"type": "Point", "coordinates": [event["longitude"], event["latitude"]]},
        }
        for event in events
    ]
    write_geojson(output_dir / "events.geojson", event_features)
    render_map(output_dir / "analysis.png", tracks, routes_wgs84, zones_wgs84, events, manifest["case_id"])
    (output_dir / "run_metadata.json").write_text(
        json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
    )
def write_geojson(path: Path, features: list[dict[str, Any]]) -> None:
    path.write_text(
        json.dumps(
            {"type": "FeatureCollection", "name": path.stem, "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": features},
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )
def render_map(
    path: Path,
    tracks: dict[str, gpd.GeoDataFrame],
    routes: gpd.GeoDataFrame,
    zones: gpd.GeoDataFrame,
    events: list[dict[str, Any]],
    case_id: str,
) -> None:
    figure, axis = plt.subplots(figsize=(10, 7), dpi=140)
    zones.plot(ax=axis, facecolor="#f0e442", edgecolor="#8c6d1f", alpha=0.28, linewidth=1.5)
    routes.plot(ax=axis, color="#666666", linestyle="--", linewidth=1.2)
    palette = ["#0072b2", "#009e73", "#d55e00", "#cc79a7", "#56b4e9"]
    for index, (track_id, track) in enumerate(sorted(tracks.items())):
        color = palette[index % len(palette)]
        axis.plot(track.geometry.x, track.geometry.y, color=color, linewidth=2.2, marker="o", markersize=2.8, label=track_id)
        axis.annotate(track_id, (track.geometry.x.iloc[-1], track.geometry.y.iloc[-1]), fontsize=7, color=color)
    for event in events:
        event_type = event["event_type"]
        axis.scatter(
            [event["longitude"]],
            [event["latitude"]],
            marker=EVENT_MARKERS[event_type],
            s=EVENT_SIZES[event_type],
            color=EVENT_COLORS[event_type],
            edgecolor="white",
            linewidth=0.8,
            zorder=5,
        )
    handles, labels = axis.get_legend_handles_labels()
    present_event_types = {event["event_type"] for event in events}
    handles.extend(
        Line2D(
            [0],
            [0],
            marker=EVENT_MARKERS[name],
            color="none",
            markerfacecolor=color,
            markeredgecolor="white",
            markersize=8,
            label=name,
        )
        for name, color in EVENT_COLORS.items()
        if name in present_event_types
    )
    labels.extend(name for name in EVENT_COLORS if name in present_event_types)
    axis.legend(handles, labels, loc="best", fontsize=7, framealpha=0.9)
    axis.set_title(f"Trajectory analysis: {case_id}")
    axis.set_xlabel("Longitude (WGS84)")
    axis.set_ylabel("Latitude (WGS84)")
    axis.grid(alpha=0.18)
    axis.ticklabel_format(useOffset=False)
    figure.tight_layout()
    figure.savefig(path)
    plt.close(figure)
def process_manifest(manifest_path: Path, output_dir: Path) -> dict[str, Any]:
    started = time.perf_counter()
    manifest, frame, routes_metric, zones_metric, duplicates, metric_crs = load_case(manifest_path)
    thresholds = dict(DEFAULT_THRESHOLDS)
    for key, value in manifest.get("thresholds", {}).items():
        if key not in thresholds:
            raise ValueError(f"Unsupported threshold: {key}")
        thresholds[key] = float(value)
        if thresholds[key] <= 0:
            raise ValueError(f"Threshold must be greater than zero: {key}")
    summaries, events, tracks = analyze_tracks(
        str(manifest["case_id"]), frame, routes_metric, zones_metric, metric_crs, thresholds
    )
    elapsed = round(time.perf_counter() - started, 3)
    metadata = {
        "created_at": datetime.now(UTC).isoformat(),
        "case_id": manifest["case_id"],
        "input_manifest": str(manifest_path.resolve()),
        "input_count": int(len(frame)),
        "track_count": len(summaries),
        "dropped_duplicate_observations": duplicates,
        "event_count": len(events),
        "event_counts": {name: sum(event["event_type"] == name for event in events) for name in EVENT_COLORS},
        "elapsed_seconds": elapsed,
        "device": "cpu",
        "python": platform.python_version(),
        "packages": {
            "geoai-py": package_version("geoai-py"),
            "pandas": package_version("pandas"),
            "geopandas": package_version("geopandas"),
            "shapely": package_version("shapely"),
            "pyproj": package_version("pyproj"),
            "scikit-learn": package_version("scikit-learn"),
            "matplotlib": package_version("matplotlib"),
        },
        "model": {
            "behavior_recognition": "deterministic-threshold-rules-v1",
            "trajectory_grouping": "DBSCAN on standardized summary features",
            "pretrained_weights": None,
        },
        "thresholds": thresholds,
        "output_crs": "EPSG:4326",
        "metric_crs": metric_crs.to_string(),
        "limitations": [
            "This is rule-based behavior detection, not learned action recognition.",
            "Track identities must already be present; this demo does not associate detections across video frames.",
            "Gathering requires aligned timestamps and is sensitive to sampling gaps and GPS error.",
            "Thresholds are illustrative and require domain validation before operational use.",
        ],
    }
    write_outputs(
        output_dir,
        manifest,
        summaries,
        events,
        tracks,
        routes_metric.to_crs("EPSG:4326"),
        zones_metric.to_crs("EPSG:4326"),
        metadata,
    )
    return metadata
def main() -> int:
    args = parse_args()
    input_path = args.input.resolve()
    output_root = args.output.resolve()
    if not input_path.exists():
        print(f"Input does not exist: {input_path}", file=sys.stderr)
        return 2
    manifests = [input_path] if input_path.is_file() else sorted(input_path.glob("*.case.json"))
    if not manifests:
        print(f"No .case.json manifests found: {input_path}", file=sys.stderr)
        return 2
    try:
        cases = [(path, str(load_json(path).get("case_id", ""))) for path in manifests]
        if any(not case_id for _, case_id in cases):
            raise ValueError("Every manifest must define a non-empty case_id.")
        destinations = [output_root / case_id for _, case_id in cases]
        existing = [path for path in destinations if path.exists()]
        if existing and not args.overwrite:
            raise ValueError(f"Output already exists: {existing[0]}. Use --overwrite to replace it.")
        results = [process_manifest(path, destination) for (path, _), destination in zip(cases, destinations, strict=True)]
        aggregate = {
            "created_at": datetime.now(UTC).isoformat(),
            "input": str(input_path),
            "input_count": sum(item["input_count"] for item in results),
            "case_count": len(results),
            "track_count": sum(item["track_count"] for item in results),
            "dropped_duplicate_observations": sum(
                item["dropped_duplicate_observations"] for item in results
            ),
            "event_count": sum(item["event_count"] for item in results),
            "event_counts": {
                name: sum(item["event_counts"][name] for item in results) for name in EVENT_COLORS
            },
            "elapsed_seconds": round(sum(item["elapsed_seconds"] for item in results), 3),
            "device": "cpu",
            "python": platform.python_version(),
            "packages": results[0]["packages"],
            "model": results[0]["model"],
            "thresholds_by_case": {
                item["case_id"]: item["thresholds"] for item in results
            },
            "limitations": results[0]["limitations"],
            "cases": [
                {"case_id": item["case_id"], "output": item["case_id"]} for item in results
            ],
        }
        output_root.mkdir(parents=True, exist_ok=True)
        (output_root / "run_metadata.json").write_text(
            json.dumps(aggregate, ensure_ascii=False, indent=2), encoding="utf-8"
        )
    except (OSError, ValueError, json.JSONDecodeError, pd.errors.ParserError) as exc:
        print(f"Analysis failed: {exc}", file=sys.stderr)
        return 2
    print(
        f"Processed {len(results)} case(s), {aggregate['track_count']} tracks and "
        f"{aggregate['event_count']} events in {aggregate['elapsed_seconds']}s"
    )
    print(f"Outputs: {output_root}")
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
capabilities/15-trajectory-analysis/tests/test_demo.py
New file
@@ -0,0 +1,73 @@
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
CAPABILITY_DIR = Path(__file__).resolve().parents[1]
GENERATOR = CAPABILITY_DIR / "generate_demo_inputs.py"
ANALYZER = CAPABILITY_DIR / "run_trajectory_analysis.py"
class TrajectoryDemoTests(unittest.TestCase):
    def run_command(self, *arguments: str) -> subprocess.CompletedProcess[str]:
        return subprocess.run(
            [sys.executable, *arguments],
            text=True,
            capture_output=True,
            encoding="utf-8",
            check=False,
        )
    def test_normal_and_difficult_cases(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            inputs = root / "inputs"
            outputs = root / "outputs"
            generated = self.run_command(str(GENERATOR), "--output", str(inputs))
            self.assertEqual(generated.returncode, 0, generated.stderr)
            analyzed = self.run_command(str(ANALYZER), "--input", str(inputs), "--output", str(outputs))
            self.assertEqual(analyzed.returncode, 0, analyzed.stderr)
            normal = json.loads((outputs / "normal" / "run_metadata.json").read_text(encoding="utf-8"))
            difficult = json.loads((outputs / "difficult" / "run_metadata.json").read_text(encoding="utf-8"))
            self.assertEqual(normal["track_count"], 2)
            self.assertEqual(normal["event_count"], 0)
            self.assertEqual(difficult["dropped_duplicate_observations"], 1)
            for event_type in ("stop", "route_deviation", "restricted_zone", "gathering"):
                self.assertGreaterEqual(difficult["event_counts"][event_type], 1)
            for case_id in ("normal", "difficult"):
                for filename in (
                    "trajectory_summary.csv",
                    "events.json",
                    "trajectories.geojson",
                    "events.geojson",
                    "analysis.png",
                    "run_metadata.json",
                ):
                    self.assertGreater((outputs / case_id / filename).stat().st_size, 0)
    def test_missing_required_column_fails_cleanly(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            inputs = root / "inputs"
            self.assertEqual(self.run_command(str(GENERATOR), "--output", str(inputs)).returncode, 0)
            csv_path = inputs / "normal_observations.csv"
            csv_path.write_text("track_id,timestamp,longitude,latitude\na,2026-01-01T00:00:00Z,1,1\n", encoding="utf-8")
            result = self.run_command(
                str(ANALYZER),
                "--input",
                str(inputs / "normal.case.json"),
                "--output",
                str(root / "outputs"),
            )
            self.assertEqual(result.returncode, 2)
            self.assertIn("entity_type", result.stderr)
if __name__ == "__main__":
    unittest.main()
capabilities/README.md
@@ -1,6 +1,23 @@
# Capabilities
目录编号表示建议研究顺序,不代表运行时依赖关系。
目录编号表示建议研究顺序,不代表运行时依赖关系,也不代表每个目录都是
`opengeos/geoai` 内置能力。
## 基于 opengeos/geoai 的能力分层
| 层级 | 含义 | 当前工作区示例 |
| --- | --- | --- |
| A:直接能力 | `geoai-py` 已有模块或文档直接覆盖 | 目标检测、分割、分类、变化检测、影像处理/导出 |
| B:生态组合 | 以 `geoai-py` 为核心,再组合地理计算或业务库 | 空间测量、成果质检、灾害统计、部分三维分析 |
| C:产品业务能力 | 主要由现有产品后端、规则和数据服务实现 | 风险规则、航线规划、集群调度、GeoLLM、知识图谱、模型治理 |
判断一个方向是否属于 GeoAI 项目,要看它是否处理地理空间数据并调用 AI 或
空间分析;判断它是否是 `geoai-py` 的能力,则必须以该仓库当前源码和文档为准。
官方依据:
- https://github.com/opengeos/geoai
- https://pypi.org/project/geoai-py/
每个能力目录至少包含:
@@ -8,4 +25,3 @@
- `requirements.txt`: 公共依赖之外的专属依赖。
开始实现时再增加 `src/`、`tests/`、`examples/` 和 `configs/`,避免初期产生大量空目录。
scripts/check-environment.ps1
@@ -6,6 +6,14 @@
Write-Host "Python installations:"
py -0p
$PythonCandidates = @(
    (Join-Path $env:LOCALAPPDATA "Programs/Python/Python312/python.exe"),
    (Join-Path $env:LOCALAPPDATA "Programs/Python/Python311/python.exe"),
    (Join-Path $env:ProgramFiles "Python312/python.exe"),
    (Join-Path $env:ProgramFiles "Python311/python.exe")
)
$SupportedPython = $PythonCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
Write-Host ""
Write-Host "Node:"
node --version
@@ -18,7 +26,8 @@
}
Write-Host ""
if ((py -0p | Select-String "3.11|3.12")) {
if ($SupportedPython) {
    Write-Host "Detected: $(& $SupportedPython --version) at $SupportedPython"
    Write-Host "Python version: ready" -ForegroundColor Green
} else {
    Write-Warning "Install Python 3.11 or 3.12 before running setup.ps1."
scripts/serve_workbench_console.py
New file
@@ -0,0 +1,91 @@
"""Serve the local GeoAI Workbench console from the repository root."""
from __future__ import annotations
import argparse
import os
from pathlib import PurePosixPath
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlsplit
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 6173
ALLOWED_PATH_PREFIXES = (
    "apps/workbench-console",
    "shared/outputs",
    "shared/data/raw/01-object-detection",
)
class WorkbenchConsoleHandler(SimpleHTTPRequestHandler):
    """Read-only static handler rooted at the workbench repository."""
    def do_GET(self) -> None:  # noqa: N802 - inherited standard-library method name
        if urlsplit(self.path).path == "/":
            self.send_response(HTTPStatus.FOUND)
            self.send_header("Location", "/apps/workbench-console/")
            self.end_headers()
            return
        super().do_GET()
    def translate_path(self, path: str) -> str:
        """Expose only the static UI and artifacts required by the local console."""
        decoded_path = unquote(urlsplit(path).path).lstrip("/")
        requested = PurePosixPath(decoded_path)
        is_allowed = any(
            decoded_path == prefix or decoded_path.startswith(f"{prefix}/")
            for prefix in ALLOWED_PATH_PREFIXES
        )
        if ".." in requested.parts or not is_allowed:
            return os.fspath(Path(self.directory) / ".console-forbidden")
        if decoded_path == "apps/workbench-console" or decoded_path.startswith("apps/workbench-console/"):
            console_relative = requested.parts[2:]
            return os.fspath(Path(self.directory) / "apps" / "workbench-console" / "dist" / Path(*console_relative))
        return os.fspath(Path(self.directory).joinpath(*requested.parts))
    def end_headers(self) -> None:
        self.send_header("Cache-Control", "no-store")
        self.send_header("X-Content-Type-Options", "nosniff")
        super().end_headers()
def parse_args() -> argparse.Namespace:
    root = Path(__file__).resolve().parents[1]
    parser = argparse.ArgumentParser(description="Serve the local GeoAI Workbench console.")
    parser.add_argument("--host", default=DEFAULT_HOST, help="Bind address. Defaults to loopback only.")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="TCP port in the 6000-6999 range.")
    parser.add_argument("--root", type=Path, default=root, help="Workbench repository root to serve.")
    return parser.parse_args()
def main() -> int:
    args = parse_args()
    if not 6000 <= args.port <= 6999:
        raise SystemExit("Port must be in the 6000-6999 range.")
    if args.host not in {"127.0.0.1", "localhost", "::1"}:
        raise SystemExit("This console is local-only. Use 127.0.0.1, localhost, or ::1.")
    root = args.root.resolve()
    app_dir = root / "apps" / "workbench-console"
    if not (root / "shared").is_dir() or not (app_dir / "dist" / "index.html").is_file():
        raise SystemExit(f"Not a GeoAI Workbench root: {root}")
    handler = lambda *handler_args, **handler_kwargs: WorkbenchConsoleHandler(  # noqa: E731
        *handler_args, directory=os.fspath(root), **handler_kwargs
    )
    server = ThreadingHTTPServer((args.host, args.port), handler)
    print(f"GeoAI Workbench console: http://{args.host}:{args.port}")
    print(f"Serving built console and read-only artifacts from: {root}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nConsole stopped.")
    finally:
        server.server_close()
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
tests/test_serve_workbench_console.py
New file
@@ -0,0 +1,46 @@
from __future__ import annotations
import importlib.util
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "serve_workbench_console.py"
SPEC = importlib.util.spec_from_file_location("serve_workbench_console", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class WorkbenchConsoleHandlerTests(unittest.TestCase):
    def make_handler(self) -> MODULE.WorkbenchConsoleHandler:
        handler = object.__new__(MODULE.WorkbenchConsoleHandler)
        handler.directory = str(ROOT)
        return handler
    def test_allows_only_console_artifacts_and_detection_originals(self) -> None:
        handler = self.make_handler()
        self.assertEqual(
            Path(handler.translate_path("/apps/workbench-console/index.html")),
            ROOT / "apps" / "workbench-console" / "dist" / "index.html",
        )
        self.assertEqual(
            Path(handler.translate_path("/shared/outputs/15-trajectory-analysis/run_metadata.json")),
            ROOT / "shared" / "outputs" / "15-trajectory-analysis" / "run_metadata.json",
        )
        self.assertEqual(
            Path(handler.translate_path("/shared/data/raw/01-object-detection/sample.jpeg")),
            ROOT / "shared" / "data" / "raw" / "01-object-detection" / "sample.jpeg",
        )
    def test_blocks_repository_files_and_encoded_traversal(self) -> None:
        handler = self.make_handler()
        forbidden = ROOT / ".console-forbidden"
        self.assertEqual(Path(handler.translate_path("/.git/HEAD")), forbidden)
        self.assertEqual(Path(handler.translate_path("/%2e%2e/.env")), forbidden)
        self.assertEqual(Path(handler.translate_path("/PROJECT_CONTEXT.md")), forbidden)
if __name__ == "__main__":
    unittest.main()
新对话启动说明.txt
New file
@@ -0,0 +1,24 @@
GeoAI Workbench 新对话启动说明
==============================
1. 新建 Codex 对话。
2. 把工作目录选择为:
   E:\AllWorkProject\geoai-workbench
3. 发送下面这句话,把“能力名称”替换成实际目标:
   使用 $geoai-capability-builder,创建下一个能力:<能力名称>。
   先读取 AGENTS.md 和 PROJECT_CONTEXT.md,再准备环境、Demo 输入输出和验收方式。
示例:
   使用 $geoai-capability-builder,创建下一个能力:语义制图。
   先读取 AGENTS.md 和 PROJECT_CONTEXT.md,再准备环境、Demo 输入输出和验收方式。
说明:
- 不需要复制旧对话。
- AGENTS.md 保存长期项目规则。
- PROJECT_CONTEXT.md 保存当前进度、环境和实验结论。
- 每完成一个能力,Codex 应更新 PROJECT_CONTEXT.md。
能力边界与来源说明.md
New file
@@ -0,0 +1,29 @@
# GeoAI 能力边界与来源说明
## 统一口径
本项目所说的 GeoAI,默认指以 `opengeos/geoai` 开源项目(PyPI 包名
`geoai-py`)为基础的地理空间人工智能工作流,而不是泛指所有空间智能、
运筹优化或大语言模型功能。
## 三层能力
1. **直接能力**:`geoai-py` 已经提供相应模块、接口或示例,例如遥感影像
   目标检测、分割、分类、变化检测、影像切片、地理结果导出和可视化。
2. **生态组合能力**:`geoai-py` 提供数据和模型工作流,但还要组合
   Rasterio、GeoPandas、Shapely、PyTorch、OpenCV 或其他库,例如空间测量、
   成果质量检查和灾害统计。
3. **产品业务能力**:不是 `geoai-py` 内置功能,而是利用其结果接入现有
   产品后实现,例如风险规则、事件工单、航线规划、集群调度、GeoLLM、知识
   图谱和模型治理。
## 许可证边界
`geoai-py` 项目本身采用 MIT 许可证。其依赖库、预训练模型、模型权重和数据集
仍需逐项核查许可证;上层库的 MIT 许可证不会自动改变下游组件的授权要求。
## 规划文件说明
此前生成的规划文件保留作为历史版本。最新规划以文件名中带有
`基于opengeos-geoai修订版` 的工作簿为准,避免把产品能力误称为
`geoai-py` 内置能力。