diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..809427f
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,60 @@
+name: Deploy probe viewer to GitHub Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch: # Allow manual trigger
+
+# Sets permissions of the GITHUB_TOKEN
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment
+concurrency:
+ group: "pages"
+ cancel-in-progress: true
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: apps/probe-viewer/package-lock.json
+
+ - name: Build probe viewer
+ # Reads probe JSONs from this repo, generates the manifest, and runs vite build.
+ run: uv run apps/probe-viewer/build.py
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload artifact
+ # The built app is the entire site, so the dist directory is the artifact root.
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: apps/probe-viewer/dist
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5e84f83
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# Probe viewer generated files (created at build time)
+apps/probe-viewer/public/data/
+apps/probe-viewer/public/probes-manifest.json
+apps/probe-viewer/node_modules/
+apps/probe-viewer/dist/
+
+# Build cache
+.cache/
+
+# Python
+__pycache__/
+*.py[cod]
+
+# OS / editor
+.DS_Store
diff --git a/apps/probe-viewer/README.md b/apps/probe-viewer/README.md
new file mode 100644
index 0000000..5c7e59e
--- /dev/null
+++ b/apps/probe-viewer/README.md
@@ -0,0 +1,198 @@
+# Probe Viewer
+
+An interactive web-based visualization tool for browsing microelectrode probe designs used in neuroscience research. The probe data comes from this repository, the [probeinterface_library](https://github.com/SpikeInterface/probeinterface_library): the build reads the manufacturer folders directly, so there is no separate data source to clone.
+
+## Local Development
+
+### Prerequisites
+
+- Node.js (v18 or later recommended)
+- Python 3.13+ with [uv](https://docs.astral.sh/uv/) package manager
+- Git
+
+### Quick Start
+
+1. **Generate the probe manifest and data files:**
+
+ From the repository root, run:
+
+ ```bash
+ uv run apps/probe-viewer/build.py --dev
+ ```
+
+ This will:
+ - Read the probe JSON files from the manufacturer folders in this repository
+ - Generate `public/probes-manifest.json` with metadata for all probes
+ - Copy probe JSON files to `public/data/`
+ - Start the Vite dev server
+
+2. **Access the app:**
+
+ Open http://localhost:5173 in your browser.
+
+### Alternative: Manual Setup
+
+If you prefer to run steps separately:
+
+1. **Generate the manifest only:**
+
+ ```bash
+ uv run apps/probe-viewer/build.py
+ ```
+
+ This generates the manifest without starting the dev server.
+
+2. **Install npm dependencies:**
+
+ ```bash
+ cd apps/probe-viewer
+ npm install
+ ```
+
+3. **Start the dev server:**
+
+ ```bash
+ npm run dev
+ ```
+
+### Available Scripts
+
+From the `apps/probe-viewer` directory:
+
+| Command | Description |
+|---------|-------------|
+| `npm run dev` | Start development server (requires the manifest to exist; generate it with `build.py` first) |
+| `npm run build` | Build the production bundle with Vite (run `build.py` first to generate the manifest and data) |
+| `npm run preview` | Preview production build locally |
+| `npm run lint` | Run ESLint |
+
+### Project Structure
+
+```
+apps/probe-viewer/
+├── src/
+│ ├── components/ # React components
+│ ├── services/ # Data fetching
+│ ├── state/ # Zustand store
+│ ├── types/ # TypeScript types
+│ └── hooks/ # Custom React hooks
+├── public/
+│ ├── probes-manifest.json # Generated probe catalog
+│ └── data/ # Generated probe JSON files
+└── index.html
+```
+
+## Technology Stack
+
+| Technology | Purpose |
+|------------|---------|
+| **React 19** | UI component framework |
+| **TypeScript** | Type-safe JavaScript |
+| **Vite** | Build tool and dev server - fast HMR, optimized production builds |
+| **Zustand** | Lightweight state management (probe cache, UI state, selections) |
+| **React Router** | Client-side routing for shareable URLs like `/#/probes/imec/NP1000` |
+| **HTML5 Canvas** | Rendering probe geometries (see below for why not SVG) |
+
+### Deployment
+
+The app is deployed to GitHub Pages via GitHub Actions (`.github/workflows/deploy.yml`). On every push to `main`:
+1. The workflow runs `apps/probe-viewer/build.py`, which reads the probe JSONs from this repository and generates the manifest
+2. Vite builds the production bundle into `apps/probe-viewer/dist/`
+3. That `dist/` directory is published as the GitHub Pages site
+
+The site is served from `/probeinterface_library/` (the Vite `base` in `vite.config.ts`), matching the project-page URL `https://spikeinterface.github.io/probeinterface_library/`.
+
+### Hash Routing vs Browser Routing
+
+This app uses **hash-based routing** (`/#/probes/imec/NP1000`) instead of browser routing (`/probes/imec/NP1000`). Here's why:
+
+**The problem with browser routing on GitHub Pages:**
+
+GitHub Pages is a static file server. When you request `/probeinterface_library/probes/imec/NP1000`:
+1. GitHub looks for a file at that exact path
+2. No such file exists (there's only `index.html`)
+3. GitHub returns 404
+4. React never loads, so React Router never gets a chance to handle the route
+
+This means direct links and page refresh would break.
+
+**How hash routing solves this:**
+
+The `#` fragment is never sent to the server. When you request `/#/probes/imec/NP1000`:
+1. Browser requests `/` from GitHub
+2. GitHub returns `index.html`
+3. React loads, reads the hash (`#/probes/imec/NP1000`)
+4. React Router renders the correct page
+
+Direct links and refresh work perfectly.
+
+**Trade-offs:**
+
+| Aspect | Browser Routing | Hash Routing |
+|--------|-----------------|--------------|
+| URLs | `/probes/imec/NP1000` | `/#/probes/imec/NP1000` |
+| GitHub Pages | Needs workarounds | Works natively |
+| SEO | Better | Worse (fragments ignored) |
+| Server-side rendering | Compatible | Not compatible |
+
+For a client-side visualization tool like this, hash routing is the pragmatic choice - the downsides (SEO, SSR) don't apply.
+
+## Why Canvas Instead of SVG?
+
+This application uses the HTML5 Canvas 2D API rather than SVG for rendering probe geometries. Both technologies could work for many probes in this catalog, but Canvas was chosen with scalability in mind.
+
+### The Trade-off
+
+SVG and Canvas represent two fundamentally different rendering architectures:
+
+- **SVG (Retained Mode)**: Each element exists as a DOM node. The browser maintains a scene graph with positions, styles, event listeners, and relationships. This makes interaction easy but creates overhead that grows with element count.
+
+- **Canvas (Immediate Mode)**: Drawing commands paint pixels to a bitmap buffer and are then forgotten. No state is retained. This requires more developer effort but eliminates DOM overhead entirely.
+
+### The Neuropixels Challenge
+
+While many probes in this catalog have modest contact counts (32-128 electrodes), Neuropixels probes push into territory where SVG performance becomes problematic:
+
+| Probe Type | Electrodes | Recording Sites to Visualize |
+|------------|------------|------------------------------|
+| Cambridge Neurotech | 32-256 | SVG handles well |
+| Neuronexus | 16-128 | SVG handles well |
+| **Neuropixels 1.0** | **960** | Borderline for SVG |
+| **Neuropixels 2.0 (single shank)** | **1,280** | Problematic for SVG |
+| **Neuropixels 2.0 (4-shank)** | **5,120** | SVG would struggle significantly |
+
+A 4-shank Neuropixels 2.0 probe has 5,120 recording sites arranged across a ~1 x 10 mm plane. Rendering this many elements as SVG DOM nodes, especially with pan/zoom interactions triggering redraws, would cause noticeable lag on many devices.
+
+### SVG Performance Thresholds
+
+Based on benchmarks from Khan Academy, Felt, and the D3.js community:
+
+| Element Count | SVG Performance |
+|---------------|-----------------|
+| < 500 | Excellent |
+| 500-1000 | Good on desktop, may stutter on mobile |
+| 1000-2000 | Noticeable lag during animations |
+| 2000-5000 | Poor experience, especially on tablets |
+| 5000+ | Unacceptable without virtualization |
+
+Canvas maintains near-constant performance regardless of element count since it only manipulates pixels in a bitmap buffer.
+
+### Why Canvas Fits This Application
+
+1. **Scales to high-density probes**: Neuropixels 2.0 with 5,120 electrodes renders as smoothly as a 32-channel probe.
+
+2. **Predictable pan/zoom performance**: Every interaction redraws all contacts. Canvas makes this explicit rather than relying on browser SVG transform optimizations (which vary significantly across browsers and devices).
+
+3. **Mobile-friendly**: Tablets and phones are common in lab settings. Canvas avoids the SVG performance cliff on resource-constrained devices.
+
+4. **No per-element interaction needed**: This viewer displays probe geometry without requiring click/hover on individual contacts. SVG's main advantage (built-in DOM events per element) goes unused.
+
+### When SVG Would Be Better
+
+SVG would be preferable if the application needed:
+- Per-contact selection, tooltips, or click handlers
+- CSS-based hover effects and transitions
+- Accessibility through per-element ARIA labels
+- Integration with React's declarative component model
+
+For a probe catalog viewer focused on displaying geometry with pan/zoom, Canvas is the pragmatic choice that ensures consistent performance across the full range of probe densities.
diff --git a/apps/probe-viewer/build.py b/apps/probe-viewer/build.py
new file mode 100755
index 0000000..77f2e90
--- /dev/null
+++ b/apps/probe-viewer/build.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = []
+# ///
+"""
+Build the probe-viewer app for the probeinterface_library GitHub Pages site.
+
+Usage (from the repository root):
+ uv run apps/probe-viewer/build.py
+
+Or make executable and run directly:
+ ./apps/probe-viewer/build.py
+
+This script:
+1. Reads the probe JSON files from this repository (the manufacturer folders)
+2. Generates the manifest and copies probe JSON files to apps/probe-viewer/public/
+3. Builds the frontend with Vite
+4. Output is in apps/probe-viewer/dist/
+
+The probe data lives in this same repository, so there is no clone step: the
+manifest is generated directly from the manufacturer folders at the repo root.
+The manifest metadata is read straight from the ProbeInterface JSON, so this
+script has no third-party dependencies.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Iterable
+
+
+# ============================================================================
+# Manifest generation
+# ============================================================================
+
+
+@dataclass
+class ManifestEntry:
+ """Serializable manifest entry for a single probe model."""
+
+ id: str
+ manufacturer: str
+ model: str
+ display_name: str
+ json_url: str
+ contact_count: int
+ shank_count: int
+ has_3d_geometry: bool
+ annotations: dict
+
+ def to_json(self) -> dict:
+ return asdict(self)
+
+
+def iter_manufacturer_dirs(base_path: Path) -> Iterable[Path]:
+ for path in sorted(base_path.iterdir()):
+ if path.is_dir() and not path.name.startswith("."):
+ yield path
+
+
+def load_probe_metadata(json_path: Path) -> ManifestEntry:
+ # The manifest metadata is read straight from the ProbeInterface JSON. The
+ # repo's JSON is the source of truth, so no probe library is needed here; the
+ # schema itself is validated separately by the data tests (tests.py).
+ probefile = json.loads(json_path.read_text(encoding="utf-8"))
+ probes = probefile.get("probes", [])
+ if not probes:
+ raise ValueError(f"No probes found in {json_path}")
+
+ manufacturer = json_path.parents[1].name
+ model = json_path.parent.name
+ probe_id = f"{manufacturer}:{model}"
+
+ total_contacts = sum(len(probe.get("contact_positions", [])) for probe in probes)
+ shank_count = max(len(set(probe.get("shank_ids") or [None])) for probe in probes)
+ has_3d = any(probe.get("ndim") == 3 for probe in probes)
+ annotations = probes[0].get("annotations") or {}
+ display_name = annotations.get("model_name") or model
+
+ return ManifestEntry(
+ id=probe_id,
+ manufacturer=manufacturer,
+ model=model,
+ display_name=display_name,
+ json_url=json_path.name,
+ contact_count=total_contacts,
+ shank_count=shank_count,
+ has_3d_geometry=has_3d,
+ annotations=annotations,
+ )
+
+
+def copy_model_assets(model_dir: Path, destination_dir: Path) -> None:
+ destination_dir.mkdir(parents=True, exist_ok=True)
+ for asset_path in model_dir.iterdir():
+ if asset_path.suffix.lower() != ".json":
+ continue
+ dest_path = destination_dir / asset_path.name
+ shutil.copy2(asset_path, dest_path)
+
+
+def generate_manifest(
+ repository_root: Path,
+ output_dir: Path,
+) -> list[ManifestEntry]:
+ entries: list[ManifestEntry] = []
+
+ data_dir = output_dir / "data"
+ if data_dir.exists():
+ shutil.rmtree(data_dir)
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ for manufacturer_dir in iter_manufacturer_dirs(repository_root):
+ manufacturer = manufacturer_dir.name
+
+ # Skip non-probe directories
+ if manufacturer in {
+ "apps",
+ "frontend",
+ "scripts",
+ "docs",
+ "tests",
+ "node_modules",
+ ".git",
+ ".github",
+ ".cache",
+ ".venv",
+ }:
+ continue
+
+ model_dirs = [
+ model_dir
+ for model_dir in iter_manufacturer_dirs(manufacturer_dir)
+ if (model_dir / f"{model_dir.name}.json").exists()
+ ]
+
+ if not model_dirs:
+ continue
+
+ for model_dir in model_dirs:
+ model = model_dir.name
+ json_path = model_dir / f"{model}.json"
+
+ try:
+ entry = load_probe_metadata(json_path)
+ except Exception as exc:
+ print(f"Warning: Failed to parse {json_path}: {exc}", file=sys.stderr)
+ continue
+
+ copy_model_assets(model_dir, data_dir / manufacturer / model)
+ entry.json_url = f"data/{manufacturer}/{model}/{entry.json_url}"
+ entries.append(entry)
+
+ entries.sort(key=lambda item: (item.manufacturer.lower(), item.model.lower()))
+ return entries
+
+
+def write_manifest(entries: Iterable[ManifestEntry], destination: Path) -> None:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ payload = [entry.to_json() for entry in entries]
+ destination.write_text(
+ json.dumps(payload, indent=2),
+ encoding="utf-8",
+ )
+
+
+# ============================================================================
+# Build logic
+# ============================================================================
+
+
+def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
+ """Run a command and print it."""
+ print(f" > {' '.join(cmd)}")
+ return subprocess.run(cmd, cwd=cwd, check=check)
+
+
+def build_frontend(frontend_dir: Path, base_path: str) -> Path:
+ """Build the frontend and return the dist directory."""
+
+ # Install dependencies if needed
+ if not (frontend_dir / "node_modules").exists():
+ print("Installing npm dependencies...")
+ run(["npm", "install"], cwd=frontend_dir)
+
+ # Build
+ print("Building frontend...")
+ run(["npx", "vite", "build", "--base", base_path], cwd=frontend_dir)
+
+ return frontend_dir / "dist"
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Build probe-viewer for GitHub Pages")
+ parser.add_argument(
+ "--base",
+ default="/probeinterface_library/",
+ help="Base public path the site is served from (default: /probeinterface_library/)",
+ )
+ parser.add_argument(
+ "--dev",
+ action="store_true",
+ help="Start dev server instead of building",
+ )
+ args = parser.parse_args()
+
+ # This script lives at apps/probe-viewer/build.py, so the frontend source is
+ # its own directory and the repo root is two levels up.
+ frontend_dir = Path(__file__).resolve().parent
+ repo_root = frontend_dir.parents[1]
+
+ # The probe data is this repository: manufacturer folders live at the repo root.
+ probe_data_root = repo_root
+
+ public_dir = frontend_dir / "public"
+
+ if not frontend_dir.exists():
+ print(f"Error: Frontend source not found at {frontend_dir}", file=sys.stderr)
+ sys.exit(1)
+
+ # Generate manifest and copy probe JSONs to public/
+ print(f"Generating manifest from {probe_data_root}...")
+ entries = generate_manifest(probe_data_root, public_dir)
+ manifest_path = public_dir / "probes-manifest.json"
+ write_manifest(entries, manifest_path)
+ print(f"Wrote {len(entries)} entries to {manifest_path}")
+
+ if args.dev:
+ # Start dev server
+ print("Starting dev server...")
+ run(["npm", "run", "dev"], cwd=frontend_dir)
+ else:
+ # Build frontend (hash routing means no 404.html redirect is needed)
+ dist_dir = build_frontend(frontend_dir, args.base)
+
+ print(f"Done! Build output at: {dist_dir}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/probe-viewer/eslint.config.js b/apps/probe-viewer/eslint.config.js
new file mode 100644
index 0000000..b19330b
--- /dev/null
+++ b/apps/probe-viewer/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs['recommended-latest'],
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/apps/probe-viewer/index.html b/apps/probe-viewer/index.html
new file mode 100644
index 0000000..ae9c19a
--- /dev/null
+++ b/apps/probe-viewer/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ ProbeInterface Library
+
+
+
+
+
+
diff --git a/apps/probe-viewer/package-lock.json b/apps/probe-viewer/package-lock.json
new file mode 100644
index 0000000..0706909
--- /dev/null
+++ b/apps/probe-viewer/package-lock.json
@@ -0,0 +1,3590 @@
+{
+ "name": "frontend",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "frontend",
+ "version": "0.0.0",
+ "dependencies": {
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-router-dom": "^7.9.4",
+ "zustand": "^5.0.8"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.36.0",
+ "@types/node": "^24.6.0",
+ "@types/react": "^19.1.16",
+ "@types/react-dom": "^19.1.9",
+ "@types/react-router-dom": "^5.3.3",
+ "@vitejs/plugin-react": "^5.0.4",
+ "eslint": "^9.36.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.22",
+ "globals": "^16.4.0",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.45.0",
+ "vite": "^7.1.11"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
+ "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
+ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.4",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.4",
+ "@babel/types": "^7.28.4",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
+ "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.3",
+ "@babel/types": "^7.28.2",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.27.2",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.28.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
+ "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.4"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
+ "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.4",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
+ "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+ "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
+ "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.6",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
+ "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.16.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
+ "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+ "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.37.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
+ "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+ "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
+ "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.16.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.38",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
+ "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
+ "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
+ "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
+ "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
+ "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
+ "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
+ "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
+ "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
+ "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
+ "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
+ "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
+ "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
+ "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
+ "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
+ "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
+ "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
+ "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
+ "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
+ "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
+ "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
+ "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
+ "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
+ "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
+ "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
+ "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
+ "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/history": {
+ "version": "4.7.11",
+ "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
+ "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.7.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
+ "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.14.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.2",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
+ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.1",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
+ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/react-router": {
+ "version": "5.1.20",
+ "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
+ "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/history": "^4.7.11",
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/react-router-dom": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
+ "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/history": "^4.7.11",
+ "@types/react": "*",
+ "@types/react-router": "*"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
+ "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.46.0",
+ "@typescript-eslint/type-utils": "8.46.0",
+ "@typescript-eslint/utils": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.46.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
+ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.46.0",
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
+ "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.46.0",
+ "@typescript-eslint/types": "^8.46.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
+ "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
+ "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
+ "integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0",
+ "@typescript-eslint/utils": "8.46.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
+ "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
+ "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.46.0",
+ "@typescript-eslint/tsconfig-utils": "8.46.0",
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
+ "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.46.0",
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
+ "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
+ "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.4",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.38",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.8.16",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
+ "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.26.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
+ "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.8.9",
+ "caniuse-lite": "^1.0.30001746",
+ "electron-to-chromium": "^1.5.227",
+ "node-releases": "^2.0.21",
+ "update-browserslist-db": "^1.1.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001750",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
+ "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.234",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz",
+ "integrity": "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.37.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
+ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.0",
+ "@eslint/config-helpers": "^0.4.0",
+ "@eslint/core": "^0.16.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.37.0",
+ "@eslint/plugin-kit": "^0.4.0",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "@types/json-schema": "^7.0.15",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.23",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.23.tgz",
+ "integrity": "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+ "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
+ "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.23",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
+ "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
+ "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
+ "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.17.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
+ "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.17.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
+ "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.17.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
+ "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.61.1",
+ "@rollup/rollup-android-arm64": "4.61.1",
+ "@rollup/rollup-darwin-arm64": "4.61.1",
+ "@rollup/rollup-darwin-x64": "4.61.1",
+ "@rollup/rollup-freebsd-arm64": "4.61.1",
+ "@rollup/rollup-freebsd-x64": "4.61.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.61.1",
+ "@rollup/rollup-linux-arm64-musl": "4.61.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.61.1",
+ "@rollup/rollup-linux-loong64-musl": "4.61.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.61.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.61.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.61.1",
+ "@rollup/rollup-linux-x64-gnu": "4.61.1",
+ "@rollup/rollup-linux-x64-musl": "4.61.1",
+ "@rollup/rollup-openbsd-x64": "4.61.1",
+ "@rollup/rollup-openharmony-arm64": "4.61.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.61.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.61.1",
+ "@rollup/rollup-win32-x64-gnu": "4.61.1",
+ "@rollup/rollup-win32-x64-msvc": "4.61.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+ "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
+ "integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.46.0",
+ "@typescript-eslint/parser": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0",
+ "@typescript-eslint/utils": "8.46.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
+ "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
+ "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
+ "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/apps/probe-viewer/package.json b/apps/probe-viewer/package.json
new file mode 100644
index 0000000..cc84bdd
--- /dev/null
+++ b/apps/probe-viewer/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-router-dom": "^7.9.4",
+ "zustand": "^5.0.8"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.36.0",
+ "@types/node": "^24.6.0",
+ "@types/react": "^19.1.16",
+ "@types/react-dom": "^19.1.9",
+ "@types/react-router-dom": "^5.3.3",
+ "@vitejs/plugin-react": "^5.0.4",
+ "eslint": "^9.36.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.22",
+ "globals": "^16.4.0",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.45.0",
+ "vite": "^7.1.11"
+ }
+}
diff --git a/apps/probe-viewer/public/.gitkeep b/apps/probe-viewer/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css
new file mode 100644
index 0000000..3b98a2a
--- /dev/null
+++ b/apps/probe-viewer/src/App.css
@@ -0,0 +1,372 @@
+.app-shell {
+ display: flex;
+ height: 100%;
+ overflow: hidden;
+ background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
+}
+
+.app-sidebar {
+ width: 320px;
+ border-right: 1px solid rgba(15, 23, 42, 0.08);
+ background: rgba(255, 255, 255, 0.9);
+ backdrop-filter: blur(6px);
+}
+
+.app-main {
+ flex: 1;
+ min-width: 0; /* allow the main area to shrink to the frame instead of overflowing */
+ min-height: 0;
+ padding: 1.5rem;
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+}
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ height: 100%;
+ padding: 2rem 1.5rem;
+ color: #0f172a;
+}
+
+.sidebar-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.sidebar-title {
+ font-size: 1.5rem;
+ margin: 0;
+}
+
+.sidebar-subtitle {
+ margin: 0;
+ color: #475569;
+ font-size: 0.95rem;
+}
+
+.sidebar-control {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.sidebar-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #475569;
+}
+
+.sidebar select,
+.sidebar input {
+ font: inherit;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.6rem;
+ border: 1px solid rgba(100, 116, 139, 0.3);
+ background-color: #f8fafc;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.sidebar select:focus,
+.sidebar input:focus {
+ border-color: #2563eb;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
+ outline: none;
+}
+
+.sidebar select:disabled,
+.sidebar input:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.sidebar-list {
+ flex: 1;
+ min-height: 0; /* let the list shrink to its slot so overflow-y: auto can scroll it */
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding-right: 0.25rem;
+}
+
+.sidebar-item {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25rem;
+ width: 100%;
+ padding: 0.8rem 0.9rem;
+ border-radius: 0.9rem;
+ border: 1px solid rgba(148, 163, 184, 0.4);
+ background: rgba(248, 250, 252, 0.8);
+ cursor: pointer;
+ transition: background 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
+}
+
+.sidebar-item:hover {
+ border-color: rgba(37, 99, 235, 0.4);
+ background: rgba(191, 219, 254, 0.35);
+}
+
+.sidebar-item--active {
+ border-color: rgba(37, 99, 235, 0.8);
+ background: rgba(191, 219, 254, 0.6);
+ box-shadow: 0 8px 18px rgba(37, 99, 235, 0.15);
+}
+
+.sidebar-item-name {
+ font-weight: 600;
+ color: #0f172a;
+}
+
+.sidebar-item-meta {
+ font-size: 0.8rem;
+ color: #475569;
+}
+
+.sidebar-hint {
+ font-size: 0.9rem;
+ color: #64748b;
+ margin: 0.5rem 0;
+}
+
+.sidebar-error {
+ font-size: 0.9rem;
+ color: #dc2626;
+ font-weight: 600;
+ margin: 0.5rem 0;
+}
+
+.viewer-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-width: 960px;
+ width: 100%;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 1.25rem;
+ padding: 2rem;
+ box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
+}
+
+.viewer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.viewer-title {
+ margin: 0;
+ font-size: 1.75rem;
+}
+
+/* "{ } JSON" link inline in the subtitle metadata line. It uses the blue link
+ color and a persistent underline (on the word, not the icon) so it clearly
+ reads as a clickable link rather than just emphasized text. */
+.viewer-json-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ vertical-align: middle; /* align the icon + text with the surrounding subtitle text */
+ font-weight: 600;
+ color: #2563eb;
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.viewer-json-link-text {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.viewer-json-link:hover {
+ color: #1d4ed8;
+}
+
+.viewer-subtitle {
+ margin: 0.25rem 0 0;
+ color: #475569;
+ font-size: 0.95rem;
+}
+
+.viewer-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.viewer-controls-group {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.viewer-controls button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ font: inherit;
+ padding: 0.45rem 0.9rem;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(37, 99, 235, 0.4);
+ background: rgba(191, 219, 254, 0.45);
+ color: #1d4ed8;
+ cursor: pointer;
+ transition: transform 0.1s ease, box-shadow 0.2s ease, background 0.2s ease;
+}
+
+.viewer-controls button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 10px 18px rgba(37, 99, 235, 0.2);
+ background: rgba(191, 219, 254, 0.7);
+}
+
+.viewer-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.9rem;
+ color: #1e293b;
+}
+
+.viewer-canvas {
+ position: relative;
+ min-height: 360px;
+ border-radius: 1.1rem;
+ border: 1px solid rgba(148, 163, 184, 0.4);
+ overflow: hidden;
+ background: #f8f9fa;
+}
+
+.viewer-canvas-surface {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: stretch;
+ justify-content: stretch;
+}
+
+.viewer-canvas canvas {
+ width: 100%;
+ height: 100%;
+ cursor: grab;
+}
+
+.viewer-canvas canvas:active {
+ cursor: grabbing;
+}
+
+.probe-overview {
+ position: absolute;
+ bottom: 12px;
+ right: 12px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15);
+ cursor: pointer;
+ transition: box-shadow 0.2s ease;
+}
+
+.probe-overview:hover {
+ box-shadow: 0 6px 16px rgba(15, 23, 42, 0.25);
+}
+
+.viewer-download {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.9rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(37, 99, 235, 0.5);
+ background: transparent;
+ color: #2563eb;
+ text-decoration: none;
+ transition: all 0.2s ease;
+ cursor: pointer;
+}
+
+.viewer-header-actions {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.viewer-download:hover {
+ background: rgba(37, 99, 235, 0.1);
+}
+
+.viewer-canvas-placeholder {
+ height: 360px;
+ border-radius: 1rem;
+ background: repeating-linear-gradient(
+ 135deg,
+ rgba(226, 232, 240, 0.7),
+ rgba(226, 232, 240, 0.7) 16px,
+ rgba(203, 213, 225, 0.7) 16px,
+ rgba(203, 213, 225, 0.7) 32px
+ ),
+ rgba(248, 250, 252, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #475569;
+ text-align: center;
+ padding: 1rem;
+}
+
+.viewer-issue-link {
+ text-align: right;
+ font-size: 0.85rem;
+}
+
+.viewer-issue-link a {
+ color: #6b7280;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.viewer-issue-link a:hover {
+ color: #2563eb;
+ text-decoration: underline;
+}
+
+.viewer-placeholder {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.85);
+ border-radius: 1.25rem;
+ color: #475569;
+ box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2);
+}
+
+.viewer-placeholder--error {
+ color: #dc2626;
+}
+
+@media (max-width: 960px) {
+ .app-shell {
+ flex-direction: column;
+ }
+
+ .app-sidebar {
+ width: 100%;
+ border-right: none;
+ border-bottom: 1px solid rgba(15, 23, 42, 0.08);
+ }
+
+ .app-main {
+ padding: 1rem;
+ }
+}
diff --git a/apps/probe-viewer/src/App.tsx b/apps/probe-viewer/src/App.tsx
new file mode 100644
index 0000000..3368bbd
--- /dev/null
+++ b/apps/probe-viewer/src/App.tsx
@@ -0,0 +1,194 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ useLocation,
+ useNavigate,
+ useParams,
+ useSearchParams,
+} from "react-router-dom";
+
+import { ProbeViewer } from "./components/ProbeViewer";
+import { Sidebar } from "./components/Sidebar";
+import { useAppStore } from "./state/useAppStore";
+import "./App.css";
+
+const DEFAULT_PROBE_ID = "plexon:8S1024";
+
+function roundForUrl(value: number, decimals = 1): number {
+ const factor = Math.pow(10, decimals);
+ return Math.round(value * factor) / factor;
+}
+
+function App() {
+ const { manufacturer, model } = useParams();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const manifestStatus = useAppStore((state) => state.manifestStatus);
+ const manifest = useAppStore((state) => state.manifest);
+ const selectedProbeId = useAppStore((state) => state.selectedProbeId);
+ const loadManifest = useAppStore((state) => state.loadManifest);
+ const selectProbe = useAppStore((state) => state.selectProbe);
+
+ const view = useAppStore((state) => state.view);
+ const setZoom = useAppStore((state) => state.setZoom);
+ const setViewCenter = useAppStore((state) => state.setViewCenter);
+
+ useEffect(() => {
+ void loadManifest();
+ }, [loadManifest]);
+
+ // Track whether we've initialized from URL to avoid overwriting on first render
+ const initializedFromUrl = useRef(false);
+
+ // Read view params from URL on initial load
+ useEffect(() => {
+ if (initializedFromUrl.current) return;
+ initializedFromUrl.current = true;
+
+ const zoomParam = searchParams.get("zoom");
+ const cxParam = searchParams.get("cx");
+ const cyParam = searchParams.get("cy");
+
+ if (zoomParam) {
+ const zoom = parseFloat(zoomParam);
+ if (!isNaN(zoom)) setZoom(zoom);
+ }
+ if (cxParam && cyParam) {
+ const cx = parseFloat(cxParam);
+ const cy = parseFloat(cyParam);
+ if (!isNaN(cx) && !isNaN(cy)) setViewCenter(cx, cy);
+ }
+ }, [searchParams, setZoom, setViewCenter]);
+
+ // Debounced URL update when view state changes
+ const updateUrlTimeout = useRef | undefined>(undefined);
+ const updateSearchParams = useCallback(() => {
+ const { zoom, viewCenterX, viewCenterY } = view;
+ const isDefault = zoom === 1 && viewCenterX === null && viewCenterY === null;
+
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev);
+ if (isDefault) {
+ next.delete("zoom");
+ next.delete("cx");
+ next.delete("cy");
+ } else {
+ next.set("zoom", String(roundForUrl(zoom, 2)));
+ if (viewCenterX !== null && viewCenterY !== null) {
+ next.set("cx", String(roundForUrl(viewCenterX, 1)));
+ next.set("cy", String(roundForUrl(viewCenterY, 1)));
+ } else {
+ next.delete("cx");
+ next.delete("cy");
+ }
+ }
+ return next;
+ }, { replace: true });
+ }, [view, setSearchParams]);
+
+ useEffect(() => {
+ if (!initializedFromUrl.current) return;
+
+ clearTimeout(updateUrlTimeout.current);
+ updateUrlTimeout.current = setTimeout(updateSearchParams, 300);
+
+ return () => clearTimeout(updateUrlTimeout.current);
+ }, [view.zoom, view.viewCenterX, view.viewCenterY, updateSearchParams]);
+
+ const manifestById = useMemo(() => {
+ const map = new Map();
+ manifest.forEach((entry) => map.set(entry.id, entry));
+ return map;
+ }, [manifest]);
+
+ useEffect(() => {
+ if (manifestStatus !== "success" || manifest.length === 0) {
+ return;
+ }
+
+ const routeId =
+ manufacturer && model ? `${manufacturer}:${model}` : undefined;
+ const routeEntry = routeId ? manifestById.get(routeId) : undefined;
+ const currentSelected = selectedProbeId
+ ? manifestById.get(selectedProbeId)
+ : undefined;
+
+ const getDefaultProbe = () =>
+ manifestById.get(DEFAULT_PROBE_ID) ?? manifest[0];
+
+ if (selectedProbeId && !currentSelected) {
+ const fallback = routeEntry ?? getDefaultProbe();
+ if (fallback && fallback.id !== selectedProbeId) {
+ selectProbe(fallback.id);
+ }
+ return;
+ }
+
+ if (!selectedProbeId) {
+ if (routeEntry) {
+ selectProbe(routeEntry.id);
+ } else {
+ const fallback = getDefaultProbe();
+ if (fallback) {
+ selectProbe(fallback.id);
+ }
+ }
+ }
+ }, [
+ manifestStatus,
+ manifest,
+ manifestById,
+ manufacturer,
+ model,
+ selectedProbeId,
+ selectProbe,
+ ]);
+
+ useEffect(() => {
+ if (
+ manifestStatus !== "success" ||
+ !selectedProbeId ||
+ manifest.length === 0
+ ) {
+ return;
+ }
+
+ const selectedEntry = manifestById.get(selectedProbeId);
+ if (!selectedEntry) {
+ return;
+ }
+
+ const routeId =
+ manufacturer && model ? `${manufacturer}:${model}` : undefined;
+ if (routeId === selectedEntry.id) {
+ return;
+ }
+
+ const targetPath = `/probes/${selectedEntry.manufacturer}/${selectedEntry.model}`;
+ const replace = location.pathname === "/";
+ navigate(targetPath, { replace });
+ }, [
+ manifestStatus,
+ selectedProbeId,
+ manifestById,
+ manufacturer,
+ model,
+ navigate,
+ location.pathname,
+ manifest.length,
+ ]);
+
+ return (
+
+ );
+}
+
+export default App;
diff --git a/apps/probe-viewer/src/assets/react.svg b/apps/probe-viewer/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/apps/probe-viewer/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx
new file mode 100644
index 0000000..d9327ba
--- /dev/null
+++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx
@@ -0,0 +1,586 @@
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type {
+ MouseEvent as ReactMouseEvent,
+ PointerEvent as ReactPointerEvent,
+} from "react";
+
+import { useResizeObserver } from "../hooks/useResizeObserver";
+import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore";
+import type { ContactShapeParams, ManifestEntry, ProbeInterfaceFile } from "../types/probe";
+
+interface ProbeCanvasProps {
+ entry: ManifestEntry;
+ probeData: ProbeInterfaceFile;
+ zoom: number;
+ viewCenterX: number | null; // probe coordinates (µm), null = geometry center
+ viewCenterY: number | null;
+ showContactIds: boolean;
+ showScaleBar: boolean;
+ onViewCenterChange: (x: number | null, y: number | null) => void;
+ onZoom: (zoom: number) => void;
+}
+
+interface GeometrySummary {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ width: number;
+ height: number;
+ centerX: number;
+ centerY: number;
+}
+
+function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary | null {
+ const probe = probeData.probes?.[0];
+ if (!probe) {
+ return null;
+ }
+
+ const positions = probe.contact_positions ?? [];
+ if (positions.length === 0) {
+ return null;
+ }
+
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+
+ const updateBounds = (point: number[]) => {
+ const [x, y] = point;
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ };
+
+ positions.forEach(updateBounds);
+ (probe.probe_planar_contour ?? []).forEach(updateBounds);
+
+ const width = Math.max(10, maxX - minX);
+ const height = Math.max(10, maxY - minY);
+ const centerX = minX + width / 2;
+ const centerY = minY + height / 2;
+
+ return { minX, maxX, minY, maxY, width, height, centerX, centerY };
+}
+
+export const ProbeCanvas = forwardRef(
+ function ProbeCanvas(
+ {
+ entry,
+ probeData,
+ zoom,
+ viewCenterX,
+ viewCenterY,
+ showContactIds,
+ showScaleBar,
+ onViewCenterChange,
+ onZoom,
+ },
+ ref
+ ) {
+ const canvasRef = useRef(null);
+
+ // Expose canvas to parent for export
+ useImperativeHandle(ref, () => canvasRef.current!, []);
+ const { ref: containerRef, size } = useResizeObserver();
+ const [isDragging, setIsDragging] = useState(false);
+ const dragOriginRef = useRef<{ x: number; y: number; viewCenterX: number; viewCenterY: number } | null>(null);
+ // Track the last applied canvas backing-store size so we only reallocate (an
+ // expensive clear + realloc of the whole pixel buffer) when the size or
+ // device-pixel-ratio actually changes, not on every pan/zoom redraw.
+ const lastCanvasSizeRef = useRef({ w: 0, h: 0, dpr: 0 });
+ // Coalesce pan updates to one per animation frame: pointermove fires far more
+ // often than the screen repaints, so we keep only the latest target.
+ const panRafRef = useRef(0);
+ const pendingViewCenterRef = useRef<{ x: number; y: number } | null>(null);
+
+ const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]);
+ const probe = useMemo(() => probeData.probes?.[0], [probeData]);
+
+ // Calculate effective view center (use geometry center if null)
+ const effectiveViewCenterX = viewCenterX ?? geometry?.centerX ?? 0;
+ const effectiveViewCenterY = viewCenterY ?? geometry?.centerY ?? 0;
+
+ useEffect(() => {
+ if (!canvasRef.current || !size.width || !size.height || !geometry || !probe) {
+ return;
+ }
+
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return;
+ }
+
+ const devicePixelRatio = window.devicePixelRatio || 1;
+ const widthPx = size.width;
+ const heightPx = size.height;
+ // Only reallocate the backing store when the size/dpr actually changes;
+ // assigning canvas.width/height clears and reallocates the whole pixel
+ // buffer, so doing it on every pan frame is wasteful (especially on dense,
+ // zoomed-in probes like Neuropixels). The per-frame clear below is cheap.
+ const targetW = Math.round(widthPx * devicePixelRatio);
+ const targetH = Math.round(heightPx * devicePixelRatio);
+ const lastSize = lastCanvasSizeRef.current;
+ if (lastSize.w !== targetW || lastSize.h !== targetH || lastSize.dpr !== devicePixelRatio) {
+ canvas.width = targetW;
+ canvas.height = targetH;
+ canvas.style.width = `${widthPx}px`;
+ canvas.style.height = `${heightPx}px`;
+ lastCanvasSizeRef.current = { w: targetW, h: targetH, dpr: devicePixelRatio };
+ }
+ ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
+
+ ctx.clearRect(0, 0, widthPx, heightPx);
+
+ const padding = 40;
+ const availableWidth = Math.max(10, widthPx - padding * 2);
+ const availableHeight = Math.max(10, heightPx - padding * 2);
+ const baseScale = Math.min(
+ availableWidth / geometry.width,
+ availableHeight / geometry.height,
+ );
+ const scale = baseScale * zoom;
+
+ // Calculate pixel pan from view center in probe coordinates
+ const panX = (geometry.centerX - effectiveViewCenterX) * scale;
+ const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+
+ const offsetX = widthPx / 2 + panX;
+ const offsetY = heightPx / 2 + panY;
+
+ const projectPoint = (point: number[]) => {
+ const [x, y] = point;
+ const normX = (x - geometry.centerX) * scale + offsetX;
+ const normY = -(y - geometry.centerY) * scale + offsetY;
+ return [normX, normY];
+ };
+
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+
+ if (probe.probe_planar_contour && probe.probe_planar_contour.length > 1) {
+ ctx.beginPath();
+ probe.probe_planar_contour.forEach((point, index) => {
+ const [x, y] = projectPoint(point);
+ if (index === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ });
+ ctx.closePath();
+ ctx.fillStyle = "rgba(180, 185, 195, 0.7)"; // Metallic silver
+ ctx.strokeStyle = "rgba(100, 105, 115, 0.95)";
+ ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 100));
+ ctx.fill();
+ ctx.stroke();
+ }
+
+ const contactPositions = probe.contact_positions ?? [];
+ const contactShapes = probe.contact_shapes ?? [];
+ const contactShapeParams = probe.contact_shape_params ?? [];
+
+ // Helper to draw a contact shape
+ const drawContactShape = (
+ x: number,
+ y: number,
+ shape: string,
+ params: ContactShapeParams,
+ ) => {
+ ctx.beginPath();
+ switch (shape) {
+ case "circle": {
+ const radius = (params.radius ?? 5) * scale;
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ break;
+ }
+ case "square": {
+ const side = (params.width ?? 10) * scale;
+ ctx.rect(x - side / 2, y - side / 2, side, side);
+ break;
+ }
+ case "rect": {
+ const w = (params.width ?? 10) * scale;
+ const h = (params.height ?? 15) * scale;
+ ctx.rect(x - w / 2, y - h / 2, w, h);
+ break;
+ }
+ default: {
+ // Unknown/missing shape: draw a dot with X to indicate missing data
+ const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100)));
+ // Draw small circle
+ ctx.arc(x, y, markerSize * 0.4, 0, Math.PI * 2);
+ ctx.closePath();
+ // Draw X through the center
+ ctx.moveTo(x - markerSize, y - markerSize);
+ ctx.lineTo(x + markerSize, y + markerSize);
+ ctx.moveTo(x + markerSize, y - markerSize);
+ ctx.lineTo(x - markerSize, y + markerSize);
+ }
+ }
+ };
+
+ // Shadow offset for depth effect - subtle, proportional to scale
+ const shadowOffset = 0.4 * scale; // 0.4 micrometer offset for subtle depth
+
+ // First pass: draw shadows (offset dark shapes)
+ contactPositions.forEach((position, index) => {
+ const [x, y] = projectPoint(position);
+ const shape = contactShapes[index] ?? "";
+ const params = contactShapeParams[index] ?? {};
+
+ drawContactShape(x + shadowOffset, y + shadowOffset, shape, params);
+ ctx.fillStyle = "rgba(30, 20, 5, 0.7)"; // Even darker and more opaque
+ ctx.fill();
+ });
+
+ // Second pass: draw gold contacts on top
+ contactPositions.forEach((position, index) => {
+ const [x, y] = projectPoint(position);
+ const shape = contactShapes[index] ?? "";
+ const params = contactShapeParams[index] ?? {};
+
+ drawContactShape(x, y, shape, params);
+
+ ctx.fillStyle = "rgba(212, 175, 55, 1.0)"; // Gold contacts - fully opaque to cover shadow
+ ctx.strokeStyle = "rgba(80, 60, 15, 0.9)"; // Dark bronze outline
+ ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150));
+ ctx.fill();
+ ctx.stroke();
+ });
+
+ if (showContactIds && probe.contact_ids) {
+ const contactIds = probe.contact_ids;
+ ctx.font = `${Math.max(10, Math.min(14, 10 * (scale / 100)))}px "Inter", sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ ctx.fillStyle = "rgba(15, 23, 42, 0.95)";
+ contactPositions.forEach((position, index) => {
+ const [x, y] = projectPoint(position);
+ // Show the probe's actual contact id, not the array index.
+ ctx.fillText(String(contactIds[index] ?? index), x, y + 4);
+ });
+ }
+
+ // === L-Shaped Scale Bar ===
+ // Renders a scale bar in the bottom-left corner showing reference lengths
+ // for both X and Y dimensions. The length adapts to zoom level using "nice" numbers.
+ const renderScaleBar = () => {
+ // Calculate adaptive scale bar length using "nice" numbers
+ const niceNumbers = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
+ const targetPixels = 80; // Target bar length in pixels
+ const targetUm = targetPixels / scale;
+ const scaleBarUm = niceNumbers.reduce((prev, curr) =>
+ Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev
+ );
+ const scaleBarPixels = scaleBarUm * scale;
+
+ // Position: bottom-left corner
+ const margin = 20;
+ const cornerX = margin;
+ const cornerY = heightPx - margin;
+ const tickSize = 4;
+
+ // Style for L shape
+ ctx.strokeStyle = "rgba(15, 23, 42, 0.9)";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "square";
+
+ // Draw L shape
+ ctx.beginPath();
+ // Vertical arm (Y) - goes up from corner
+ ctx.moveTo(cornerX, cornerY);
+ ctx.lineTo(cornerX, cornerY - scaleBarPixels);
+ // Horizontal arm (X) - goes right from corner
+ ctx.moveTo(cornerX, cornerY);
+ ctx.lineTo(cornerX + scaleBarPixels, cornerY);
+ ctx.stroke();
+
+ // End ticks
+ ctx.beginPath();
+ // Top of vertical arm
+ ctx.moveTo(cornerX - tickSize, cornerY - scaleBarPixels);
+ ctx.lineTo(cornerX + tickSize, cornerY - scaleBarPixels);
+ // Right of horizontal arm
+ ctx.moveTo(cornerX + scaleBarPixels, cornerY - tickSize);
+ ctx.lineTo(cornerX + scaleBarPixels, cornerY + tickSize);
+ ctx.stroke();
+
+ // Labels
+ const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`;
+ ctx.font = '11px "Inter", sans-serif';
+ ctx.fillStyle = "rgba(15, 23, 42, 0.9)";
+
+ // X label (below horizontal arm)
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ ctx.fillText(label, cornerX + scaleBarPixels / 2, cornerY + 5);
+
+ // Y label (rotated, to the left of vertical arm)
+ ctx.save();
+ ctx.translate(cornerX - 6, cornerY - scaleBarPixels / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.textAlign = "center";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(label, 0, 0);
+ ctx.restore();
+ };
+
+ if (showScaleBar) {
+ renderScaleBar();
+ }
+ }, [entry.id, effectiveViewCenterX, effectiveViewCenterY, geometry, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom]);
+
+ const clampZoom = useCallback(
+ (value: number) => Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, value)),
+ [],
+ );
+
+ // Helper to calculate scale (needed for coordinate conversion in handlers)
+ const getScale = useCallback(() => {
+ if (!size.width || !size.height || !geometry) return 1;
+ const padding = 40;
+ const availableWidth = Math.max(10, size.width - padding * 2);
+ const availableHeight = Math.max(10, size.height - padding * 2);
+ const baseScale = Math.min(
+ availableWidth / geometry.width,
+ availableHeight / geometry.height,
+ );
+ return baseScale * zoom;
+ }, [geometry, size.width, size.height, zoom]);
+
+ // Wheel-to-zoom is attached as a NATIVE, non-passive listener (not React's
+ // onWheel) so preventDefault() actually stops the page from scrolling. React
+ // registers wheel handlers as passive by default, which ignores preventDefault()
+ // and lets the page scroll while we zoom. The listener lives only on the canvas.
+ // Live values are read through a ref so the listener does not re-subscribe on
+ // every zoom/pan change; it only re-attaches when the canvas itself changes.
+ const wheelStateRef = useRef({
+ zoom,
+ effectiveViewCenterX,
+ effectiveViewCenterY,
+ geometry,
+ getScale,
+ clampZoom,
+ onViewCenterChange,
+ onZoom,
+ });
+ wheelStateRef.current = {
+ zoom,
+ effectiveViewCenterX,
+ effectiveViewCenterY,
+ geometry,
+ getScale,
+ clampZoom,
+ onViewCenterChange,
+ onZoom,
+ };
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const handleWheel = (event: WheelEvent) => {
+ event.preventDefault();
+ const {
+ zoom,
+ effectiveViewCenterX,
+ effectiveViewCenterY,
+ geometry,
+ getScale,
+ clampZoom,
+ onViewCenterChange,
+ onZoom,
+ } = wheelStateRef.current;
+ if (!geometry) return;
+
+ // Normalize wheel units so zoom speed is consistent across devices: mouse
+ // wheels often report "line" deltas, trackpads report pixels.
+ const unit =
+ event.deltaMode === 1
+ ? 16 // lines -> ~16px
+ : event.deltaMode === 2
+ ? canvas.clientHeight // pages -> viewport height
+ : 1; // already pixels
+ // Holding Shift moves the scroll onto the horizontal axis on most systems.
+ const delta = (event.deltaY || event.deltaX) * unit;
+
+ const rect = canvas.getBoundingClientRect();
+ const offsetFromCenterX = event.clientX - rect.left - rect.width / 2;
+ const offsetFromCenterY = event.clientY - rect.top - rect.height / 2;
+
+ const scale = getScale();
+ const panX = (geometry.centerX - effectiveViewCenterX) * scale;
+ const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+
+ const zoomFactor = Math.exp(-delta * 0.002);
+ const nextZoom = clampZoom(zoom * zoomFactor);
+ const actualZoomFactor = nextZoom / zoom;
+
+ // Keep the point under the cursor fixed. The (1 - factor) sign anchors the
+ // zoom at the cursor; (factor - 1) would anchor at the cursor's mirror across
+ // the center, which is what made zoom feel like it pulled toward the middle.
+ const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor);
+ const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor);
+
+ // Convert back to probe coordinates.
+ const newScale = scale * actualZoomFactor;
+ const newViewCenterX = geometry.centerX - newPanX / newScale;
+ const newViewCenterY = geometry.centerY + newPanY / newScale;
+
+ onViewCenterChange(newViewCenterX, newViewCenterY);
+ onZoom(nextZoom);
+ };
+
+ canvas.addEventListener("wheel", handleWheel, { passive: false });
+ return () => canvas.removeEventListener("wheel", handleWheel);
+ }, [geometry, probe]);
+
+ const handlePointerDown = useCallback((event: ReactPointerEvent) => {
+ event.preventDefault();
+ setIsDragging(true);
+ dragOriginRef.current = {
+ x: event.clientX,
+ y: event.clientY,
+ viewCenterX: effectiveViewCenterX,
+ viewCenterY: effectiveViewCenterY,
+ };
+ (event.target as HTMLCanvasElement).setPointerCapture(event.pointerId);
+ }, [effectiveViewCenterX, effectiveViewCenterY]);
+
+ const handlePointerMove = useCallback((event: ReactPointerEvent) => {
+ if (!isDragging || !dragOriginRef.current) {
+ return;
+ }
+ event.preventDefault();
+ const deltaX = event.clientX - dragOriginRef.current.x;
+ const deltaY = event.clientY - dragOriginRef.current.y;
+
+ // Convert pixel delta to probe coordinate delta, but only apply one update
+ // per animation frame so a flood of pointermove events collapses into a
+ // single redraw.
+ const scale = getScale();
+ pendingViewCenterRef.current = {
+ x: dragOriginRef.current.viewCenterX - deltaX / scale,
+ y: dragOriginRef.current.viewCenterY + deltaY / scale,
+ };
+ if (!panRafRef.current) {
+ panRafRef.current = requestAnimationFrame(() => {
+ panRafRef.current = 0;
+ const pending = pendingViewCenterRef.current;
+ if (pending) onViewCenterChange(pending.x, pending.y);
+ });
+ }
+ }, [getScale, isDragging, onViewCenterChange]);
+
+ const handlePointerUp = useCallback((event: ReactPointerEvent) => {
+ if (isDragging) {
+ event.preventDefault();
+ // Flush any pending coalesced pan so the final position is exact.
+ if (panRafRef.current) {
+ cancelAnimationFrame(panRafRef.current);
+ panRafRef.current = 0;
+ }
+ const pending = pendingViewCenterRef.current;
+ if (pending) {
+ onViewCenterChange(pending.x, pending.y);
+ pendingViewCenterRef.current = null;
+ }
+ setIsDragging(false);
+ dragOriginRef.current = null;
+ (event.target as HTMLCanvasElement).releasePointerCapture(event.pointerId);
+ }
+ }, [isDragging, onViewCenterChange]);
+
+ // Cancel any pending pan frame on unmount.
+ useEffect(() => {
+ return () => {
+ if (panRafRef.current) cancelAnimationFrame(panRafRef.current);
+ };
+ }, []);
+
+ const handleDoubleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ event.preventDefault();
+ if (!geometry) return;
+
+ // Get click position relative to canvas
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const rect = canvas.getBoundingClientRect();
+ const mouseX = event.clientX - rect.left;
+ const mouseY = event.clientY - rect.top;
+
+ // Canvas center
+ const canvasCenterX = rect.width / 2;
+ const canvasCenterY = rect.height / 2;
+
+ // Mouse offset from center
+ const offsetFromCenterX = mouseX - canvasCenterX;
+ const offsetFromCenterY = mouseY - canvasCenterY;
+
+ // Calculate scale and pan in pixels
+ const scale = getScale();
+ const panX = (geometry.centerX - effectiveViewCenterX) * scale;
+ const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+
+ // Calculate new zoom
+ const zoomFactor = event.shiftKey ? 1 / 1.5 : 1.5;
+ const nextZoom = clampZoom(zoom * zoomFactor);
+ const actualZoomFactor = nextZoom / zoom;
+
+ // Adjust pan so the clicked point stays fixed (see wheel handler note on
+ // the (1 - factor) sign that anchors at the cursor rather than its mirror).
+ const newPanX = panX * actualZoomFactor + offsetFromCenterX * (1 - actualZoomFactor);
+ const newPanY = panY * actualZoomFactor + offsetFromCenterY * (1 - actualZoomFactor);
+
+ // Convert back to probe coordinates
+ const newScale = scale * actualZoomFactor;
+ const newViewCenterX = geometry.centerX - newPanX / newScale;
+ const newViewCenterY = geometry.centerY + newPanY / newScale;
+
+ onViewCenterChange(newViewCenterX, newViewCenterY);
+ onZoom(nextZoom);
+ },
+ [clampZoom, effectiveViewCenterX, effectiveViewCenterY, geometry, getScale, onViewCenterChange, onZoom, zoom],
+ );
+
+ return (
+
+ {geometry && probe ? (
+
+ ) : (
+
+
No planar geometry available for this probe.
+
+ )}
+
+ );
+});
diff --git a/apps/probe-viewer/src/components/ProbeOverview.tsx b/apps/probe-viewer/src/components/ProbeOverview.tsx
new file mode 100644
index 0000000..5fb6edc
--- /dev/null
+++ b/apps/probe-viewer/src/components/ProbeOverview.tsx
@@ -0,0 +1,243 @@
+import { useEffect, useRef, useMemo } from "react";
+import type { ProbeInterfaceFile } from "../types/probe";
+
+interface ProbeOverviewProps {
+ probeData: ProbeInterfaceFile;
+ zoom: number;
+ viewCenterX: number | null; // probe coordinates (µm), null = geometry center
+ viewCenterY: number | null;
+ /** Main canvas dimensions */
+ mainWidth: number;
+ mainHeight: number;
+ onViewCenterChange?: (x: number | null, y: number | null) => void;
+}
+
+interface GeometrySummary {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ width: number;
+ height: number;
+ centerX: number;
+ centerY: number;
+}
+
+function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary | null {
+ const probe = probeData.probes?.[0];
+ if (!probe) return null;
+
+ const positions = probe.contact_positions ?? [];
+ if (positions.length === 0) return null;
+
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+
+ const updateBounds = (point: number[]) => {
+ const [x, y] = point;
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ };
+
+ positions.forEach(updateBounds);
+ (probe.probe_planar_contour ?? []).forEach(updateBounds);
+
+ const width = Math.max(10, maxX - minX);
+ const height = Math.max(10, maxY - minY);
+ const centerX = minX + width / 2;
+ const centerY = minY + height / 2;
+
+ return { minX, maxX, minY, maxY, width, height, centerX, centerY };
+}
+
+export function ProbeOverview({
+ probeData,
+ zoom,
+ viewCenterX,
+ viewCenterY,
+ mainWidth,
+ mainHeight,
+ onViewCenterChange,
+}: ProbeOverviewProps) {
+ const canvasRef = useRef(null);
+ const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]);
+ const probe = useMemo(() => probeData.probes?.[0], [probeData]);
+
+ // Calculate effective view center (use geometry center if null)
+ const effectiveViewCenterX = viewCenterX ?? geometry?.centerX ?? 0;
+ const effectiveViewCenterY = viewCenterY ?? geometry?.centerY ?? 0;
+
+ // Fixed minimap size
+ const MINIMAP_WIDTH = 120;
+ const MINIMAP_HEIGHT = 160;
+
+ useEffect(() => {
+ if (!canvasRef.current || !geometry || !probe) return;
+
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = MINIMAP_WIDTH * dpr;
+ canvas.height = MINIMAP_HEIGHT * dpr;
+ canvas.style.width = `${MINIMAP_WIDTH}px`;
+ canvas.style.height = `${MINIMAP_HEIGHT}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ // Clear with background
+ ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
+ ctx.fillRect(0, 0, MINIMAP_WIDTH, MINIMAP_HEIGHT);
+
+ // Draw title at top
+ const titleHeight = 16;
+ ctx.font = '9px "Inter", sans-serif';
+ ctx.fillStyle = "rgba(71, 85, 105, 0.9)";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ ctx.fillText("Full probe view", MINIMAP_WIDTH / 2, 4);
+
+ // Calculate scale to fit probe in minimap (accounting for title)
+ const padding = 8;
+ const availW = MINIMAP_WIDTH - padding * 2;
+ const availH = MINIMAP_HEIGHT - padding * 2 - titleHeight;
+ const minimapScale = Math.min(availW / geometry.width, availH / geometry.height);
+
+ const offsetX = MINIMAP_WIDTH / 2;
+ const offsetY = (MINIMAP_HEIGHT + titleHeight) / 2; // Shift down to account for title
+
+ const projectPoint = (point: number[]) => {
+ const [x, y] = point;
+ return [
+ (x - geometry.centerX) * minimapScale + offsetX,
+ -(y - geometry.centerY) * minimapScale + offsetY,
+ ];
+ };
+
+ // Draw probe contour
+ if (probe.probe_planar_contour && probe.probe_planar_contour.length > 1) {
+ ctx.beginPath();
+ probe.probe_planar_contour.forEach((point, index) => {
+ const [x, y] = projectPoint(point);
+ if (index === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ });
+ ctx.closePath();
+ ctx.fillStyle = "rgba(180, 185, 195, 0.8)";
+ ctx.strokeStyle = "rgba(100, 105, 115, 0.95)";
+ ctx.lineWidth = 1;
+ ctx.fill();
+ ctx.stroke();
+ }
+
+ // Calculate and draw viewport rectangle
+ // In main canvas: scale = baseScale * zoom, where baseScale fits probe to mainCanvas
+ const mainPadding = 40;
+ const mainAvailW = Math.max(10, mainWidth - mainPadding * 2);
+ const mainAvailH = Math.max(10, mainHeight - mainPadding * 2);
+ const mainBaseScale = Math.min(mainAvailW / geometry.width, mainAvailH / geometry.height);
+ const mainScale = mainBaseScale * zoom;
+
+ // Visible area in probe coordinates (micrometers)
+ const visibleWidthUm = mainWidth / mainScale;
+ const visibleHeightUm = mainHeight / mainScale;
+
+ // Convert to minimap coordinates using the effective view center
+ const viewRectWidth = visibleWidthUm * minimapScale;
+ const viewRectHeight = visibleHeightUm * minimapScale;
+ const viewRectX = (effectiveViewCenterX - geometry.centerX) * minimapScale + offsetX - viewRectWidth / 2;
+ const viewRectY = -(effectiveViewCenterY - geometry.centerY) * minimapScale + offsetY - viewRectHeight / 2;
+
+ // Draw viewport rectangle
+ ctx.strokeStyle = "rgba(59, 130, 246, 0.9)"; // Blue
+ ctx.fillStyle = "rgba(59, 130, 246, 0.15)";
+ ctx.lineWidth = 2;
+ ctx.fillRect(viewRectX, viewRectY, viewRectWidth, viewRectHeight);
+ ctx.strokeRect(viewRectX, viewRectY, viewRectWidth, viewRectHeight);
+
+ // Scale bar in bottom-left corner
+ const niceNumbers = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
+ const targetBarPixels = 30; // Target bar length in pixels
+ const targetUm = targetBarPixels / minimapScale;
+ const scaleBarUm = niceNumbers.reduce((prev, curr) =>
+ Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev
+ );
+ const scaleBarPixels = scaleBarUm * minimapScale;
+
+ const barMargin = 6;
+ const barY = MINIMAP_HEIGHT - barMargin;
+ const barX = barMargin;
+
+ // Draw scale bar line
+ ctx.strokeStyle = "rgba(15, 23, 42, 0.8)";
+ ctx.lineWidth = 1.5;
+ ctx.lineCap = "square";
+ ctx.beginPath();
+ ctx.moveTo(barX, barY);
+ ctx.lineTo(barX + scaleBarPixels, barY);
+ ctx.stroke();
+
+ // End ticks
+ ctx.beginPath();
+ ctx.moveTo(barX, barY - 3);
+ ctx.lineTo(barX, barY + 1);
+ ctx.moveTo(barX + scaleBarPixels, barY - 3);
+ ctx.lineTo(barX + scaleBarPixels, barY + 1);
+ ctx.stroke();
+
+ // Label
+ const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`;
+ ctx.font = '8px "Inter", sans-serif';
+ ctx.fillStyle = "rgba(15, 23, 42, 0.8)";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(label, barX + scaleBarPixels / 2, barY - 4);
+
+ // Border around minimap
+ ctx.strokeStyle = "rgba(100, 105, 115, 0.5)";
+ ctx.lineWidth = 1;
+ ctx.strokeRect(0.5, 0.5, MINIMAP_WIDTH - 1, MINIMAP_HEIGHT - 1);
+
+ }, [geometry, probe, zoom, effectiveViewCenterX, effectiveViewCenterY, mainWidth, mainHeight]);
+
+ // Handle click to pan
+ const handleClick = (event: React.MouseEvent) => {
+ if (!geometry || !onViewCenterChange || mainWidth === 0 || mainHeight === 0) return;
+
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const clickX = event.clientX - rect.left;
+ const clickY = event.clientY - rect.top;
+
+ // Account for title offset
+ const titleHeight = 16;
+ const padding = 8;
+ const availW = MINIMAP_WIDTH - padding * 2;
+ const availH = MINIMAP_HEIGHT - padding * 2 - titleHeight;
+ const minimapScale = Math.min(availW / geometry.width, availH / geometry.height);
+
+ const offsetX = MINIMAP_WIDTH / 2;
+ const offsetY = (MINIMAP_HEIGHT + titleHeight) / 2;
+
+ // Convert click position to probe coordinates
+ const probeX = (clickX - offsetX) / minimapScale + geometry.centerX;
+ const probeY = geometry.centerY - (clickY - offsetY) / minimapScale; // Y inverted
+
+ // Set view center to the clicked point
+ onViewCenterChange(probeX, probeY);
+ };
+
+ if (!geometry || !probe) return null;
+
+ return (
+
+ );
+}
diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx
new file mode 100644
index 0000000..d081039
--- /dev/null
+++ b/apps/probe-viewer/src/components/ProbeViewer.tsx
@@ -0,0 +1,406 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { useResizeObserver } from "../hooks/useResizeObserver";
+import { useAppStore, VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore";
+import { exportProbeAsPng, exportProbeAsSvg } from "../utils/exportUtils";
+import { ProbeCanvas } from "./ProbeCanvas";
+import { ProbeOverview } from "./ProbeOverview";
+
+const ZoomInIcon = (
+
+
+
+
+
+
+);
+
+const ZoomOutIcon = (
+
+
+
+
+
+);
+
+const ShareIcon = (
+
+
+
+
+
+
+
+);
+
+const CheckIcon = (
+
+
+
+);
+
+// Curly-braces glyph, the de-facto standard symbol for JSON/code.
+const JsonIcon = (
+
+
+
+
+);
+
+export function ProbeViewer() {
+ const manifest = useAppStore((state) => state.manifest);
+ const manifestStatus = useAppStore((state) => state.manifestStatus);
+ const manifestError = useAppStore((state) => state.manifestError);
+ const selectedProbeId = useAppStore((state) => state.selectedProbeId);
+ const ensureProbeLoaded = useAppStore((state) => state.ensureProbeLoaded);
+ const probeCache = useAppStore((state) => state.probeCache);
+ const probeStatus = useAppStore((state) => state.probeStatus);
+ const view = useAppStore((state) => state.view);
+ const setZoom = useAppStore((state) => state.setZoom);
+ const setViewCenter = useAppStore((state) => state.setViewCenter);
+ const resetView = useAppStore((state) => state.resetView);
+ const toggleContactIds = useAppStore((state) => state.toggleContactIds);
+ const toggleScaleBar = useAppStore((state) => state.toggleScaleBar);
+ const toggleOverview = useAppStore((state) => state.toggleOverview);
+
+ useEffect(() => {
+ if (selectedProbeId) {
+ void ensureProbeLoaded(selectedProbeId);
+ }
+ }, [selectedProbeId, ensureProbeLoaded]);
+
+ const entry = useMemo(
+ () => manifest.find((item) => item.id === selectedProbeId),
+ [manifest, selectedProbeId],
+ );
+
+ const status = selectedProbeId
+ ? probeStatus[selectedProbeId]?.status ?? "idle"
+ : "idle";
+ const statusMessage = selectedProbeId
+ ? probeStatus[selectedProbeId]?.error
+ : manifestError;
+
+ const probeData = selectedProbeId ? probeCache[selectedProbeId] : undefined;
+
+ // Only offer the "Show contact IDs" toggle when the probe actually carries them.
+ const hasContactIds = !!probeData?.probes?.[0]?.contact_ids?.length;
+
+ // Track canvas container size for minimap
+ const { ref: canvasContainerRef, size: canvasSize } = useResizeObserver();
+
+ // Export handlers
+ const handleExportPng = useCallback(() => {
+ if (probeData && entry) {
+ exportProbeAsPng(
+ probeData,
+ { zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY },
+ { width: canvasSize.width, height: canvasSize.height },
+ `${entry.id}.png`,
+ view.showScaleBar
+ );
+ }
+ }, [probeData, entry, view.zoom, view.viewCenterX, view.viewCenterY, canvasSize.width, canvasSize.height, view.showScaleBar]);
+
+ const handleExportSvg = useCallback(() => {
+ if (probeData && entry) {
+ exportProbeAsSvg(
+ probeData,
+ { zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY },
+ { width: canvasSize.width, height: canvasSize.height },
+ `${entry.id}.svg`,
+ view.showScaleBar
+ );
+ }
+ }, [probeData, entry, view.zoom, view.viewCenterX, view.viewCenterY, canvasSize.width, canvasSize.height, view.showScaleBar]);
+
+ const [shareCopied, setShareCopied] = useState(false);
+ const handleShareView = useCallback(() => {
+ navigator.clipboard.writeText(window.location.href).then(() => {
+ setShareCopied(true);
+ setTimeout(() => setShareCopied(false), 2000);
+ });
+ }, []);
+
+ const lastResetProbeId = useRef(undefined);
+
+ useEffect(() => {
+ if (selectedProbeId && lastResetProbeId.current !== selectedProbeId) {
+ // Get current view state directly from store (not stale closure value)
+ // This is critical because App.tsx's URL effect may have updated the store
+ // after this component rendered but before this effect runs
+ const currentView = useAppStore.getState().view;
+ const hasUrlViewState = currentView.zoom !== 1 || currentView.viewCenterX !== null || currentView.viewCenterY !== null;
+ if (!hasUrlViewState) {
+ resetView();
+ }
+ lastResetProbeId.current = selectedProbeId;
+ }
+ if (!selectedProbeId) {
+ lastResetProbeId.current = undefined;
+ }
+ }, [selectedProbeId, resetView]);
+
+ // Smart initial zoom and pan for very tall probes (like Neuropixels)
+ // When probe geometry has extreme aspect ratio, zoom in so probe is ~1/3 of viewport width
+ // and pan to show the bottom (base) of the probe
+ const lastSmartZoomProbeId = useRef(undefined);
+ useEffect(() => {
+ if (!probeData || !selectedProbeId) return;
+ if (lastSmartZoomProbeId.current === selectedProbeId) return;
+ // Wait for canvas size to be available
+ if (canvasSize.width === 0 || canvasSize.height === 0) return;
+
+ // Get current view state directly from store (not stale closure value)
+ const currentView = useAppStore.getState().view;
+ const hasUrlViewState = currentView.zoom !== 1 || currentView.viewCenterX !== null || currentView.viewCenterY !== null;
+ if (hasUrlViewState) {
+ lastSmartZoomProbeId.current = selectedProbeId;
+ return;
+ }
+
+ const probe = probeData.probes?.[0];
+ if (!probe) return;
+
+ const positions = probe.contact_positions ?? [];
+ const contour = probe.probe_planar_contour ?? [];
+ if (positions.length === 0) return;
+
+ // Calculate geometry bounds
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ const updateBounds = (point: number[]) => {
+ const [x, y] = point;
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ };
+ positions.forEach(updateBounds);
+ contour.forEach(updateBounds);
+
+ const width = Math.max(10, maxX - minX);
+ const height = Math.max(10, maxY - minY);
+ const centerX = minX + width / 2;
+ const aspectRatio = height / width;
+
+ const TALL_THRESHOLD = 10;
+ const TARGET_WIDTH_FRACTION = 1 / 3;
+
+ if (aspectRatio > TALL_THRESHOLD) {
+ // For very tall probes, start zoomed in
+ const initialZoom = aspectRatio * TARGET_WIDTH_FRACTION;
+ setZoom(initialZoom);
+
+ // Set view center to show the bottom (base) of the probe
+ // We want minY (probe base) to appear near bottom of viewport
+ // Calculate the Y coordinate that should be at screen center
+ const mainPadding = 40;
+ const mainAvailW = Math.max(10, canvasSize.width - mainPadding * 2);
+ const mainAvailH = Math.max(10, canvasSize.height - mainPadding * 2);
+ const mainBaseScale = Math.min(mainAvailW / width, mainAvailH / height);
+ const mainScale = mainBaseScale * initialZoom;
+
+ // How much of probe height fits in the viewport?
+ const viewportHeightInProbeUnits = (canvasSize.height - mainPadding * 2) / mainScale;
+ // Center the view so minY is near the bottom edge
+ const initialViewCenterY = minY + viewportHeightInProbeUnits / 2;
+
+ setViewCenter(centerX, initialViewCenterY);
+ }
+
+ lastSmartZoomProbeId.current = selectedProbeId;
+ }, [probeData, selectedProbeId, setZoom, setViewCenter, canvasSize.width, canvasSize.height]);
+
+ if (manifestStatus === "loading") {
+ return (
+
+ );
+ }
+
+ if (manifestStatus === "error") {
+ return (
+
+
{statusMessage ?? "Unable to load catalog."}
+
+ );
+ }
+
+ if (!entry) {
+ return (
+
+
Select a probe to see its details.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {status === "error" && (
+
+
{statusMessage ?? "Failed to load probe data."}
+
+ )}
+ {status !== "error" && probeData && (
+ <>
+ setViewCenter(x, y)}
+ onZoom={(value) => setZoom(value)}
+ />
+ {view.showOverview && (
+ setViewCenter(x, y)}
+ />
+ )}
+ >
+ )}
+ {status === "loading" && (
+
+
Loading probe geometry…
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/probe-viewer/src/components/Sidebar.tsx b/apps/probe-viewer/src/components/Sidebar.tsx
new file mode 100644
index 0000000..5a3477a
--- /dev/null
+++ b/apps/probe-viewer/src/components/Sidebar.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useMemo } from "react";
+
+import { useAppStore } from "../state/useAppStore";
+
+const MANUFACTURER_DISPLAY_NAMES: Record = {
+ cambridgeneurotech: "Cambridge NeuroTech",
+ imec: "IMEC (Neuropixels)",
+ neuronexus: "NeuroNexus",
+ plexon: "Plexon",
+ "sinaps-research-platform": "SINAPS",
+};
+
+export function Sidebar() {
+ const manifest = useAppStore((state) => state.manifest);
+ const manifestStatus = useAppStore((state) => state.manifestStatus);
+ const selectedManufacturer = useAppStore((state) => state.selectedManufacturer);
+ const selectManufacturer = useAppStore((state) => state.selectManufacturer);
+ const selectedProbeId = useAppStore((state) => state.selectedProbeId);
+ const selectProbe = useAppStore((state) => state.selectProbe);
+ const searchQuery = useAppStore((state) => state.searchQuery);
+ const setSearchQuery = useAppStore((state) => state.setSearchQuery);
+
+ const manufacturers = useMemo(() => {
+ const unique = new Set();
+ manifest.forEach((entry) => unique.add(entry.manufacturer));
+ return Array.from(unique.values()).sort((a, b) =>
+ a.localeCompare(b, undefined, { sensitivity: "base" }),
+ );
+ }, [manifest]);
+
+ useEffect(() => {
+ if (!selectedManufacturer && manufacturers.length > 0) {
+ selectManufacturer(manufacturers[0]);
+ }
+ }, [manufacturers, selectedManufacturer, selectManufacturer]);
+
+ const filteredEntries = useMemo(() => {
+ const query = searchQuery.trim().toLowerCase();
+ return manifest.filter((entry) => {
+ if (selectedManufacturer && entry.manufacturer !== selectedManufacturer) {
+ return false;
+ }
+ if (!query) {
+ return true;
+ }
+ return (
+ entry.model.toLowerCase().includes(query) ||
+ entry.displayName.toLowerCase().includes(query)
+ );
+ });
+ }, [manifest, selectedManufacturer, searchQuery]);
+
+ useEffect(() => {
+ if (
+ filteredEntries.length > 0 &&
+ !filteredEntries.some((entry) => entry.id === selectedProbeId)
+ ) {
+ selectProbe(filteredEntries[0].id);
+ }
+ }, [filteredEntries, selectedProbeId, selectProbe]);
+
+ return (
+
+
+
+
+
+ Manufacturer
+
+ selectManufacturer(event.target.value || undefined)}
+ disabled={manifestStatus !== "success"}
+ >
+ {manufacturers.map((manufacturer) => (
+
+ {MANUFACTURER_DISPLAY_NAMES[manufacturer] ?? manufacturer}
+
+ ))}
+
+
+
+
+
+ Search by model
+
+ setSearchQuery(event.target.value)}
+ disabled={manifestStatus !== "success"}
+ />
+
+
+
+ {manifestStatus === "loading" && (
+
Loading manifest…
+ )}
+ {manifestStatus === "error" && (
+
Failed to load manifest.
+ )}
+ {manifestStatus === "success" && filteredEntries.length === 0 && (
+
No probes match the current filters.
+ )}
+ {filteredEntries.map((entry) => (
+
selectProbe(entry.id)}
+ >
+ {entry.displayName}
+
+ {entry.contactCount} contacts · {entry.shankCount} shanks
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/probe-viewer/src/hooks/useResizeObserver.ts b/apps/probe-viewer/src/hooks/useResizeObserver.ts
new file mode 100644
index 0000000..3eaff2d
--- /dev/null
+++ b/apps/probe-viewer/src/hooks/useResizeObserver.ts
@@ -0,0 +1,28 @@
+import { useCallback, useRef, useState } from "react";
+
+interface Size {
+ width: number;
+ height: number;
+}
+
+export function useResizeObserver() {
+ const observerRef = useRef(null);
+ const [size, setSize] = useState({ width: 0, height: 0 });
+
+ const callbackRef = useCallback((node: T | null) => {
+ if (observerRef.current) {
+ observerRef.current.disconnect();
+ observerRef.current = null;
+ }
+
+ if (node) {
+ observerRef.current = new ResizeObserver(([entry]) => {
+ const box = entry.contentRect;
+ setSize({ width: box.width, height: box.height });
+ });
+ observerRef.current.observe(node);
+ }
+ }, []);
+
+ return { ref: callbackRef, size };
+}
diff --git a/apps/probe-viewer/src/index.css b/apps/probe-viewer/src/index.css
new file mode 100644
index 0000000..4d29991
--- /dev/null
+++ b/apps/probe-viewer/src/index.css
@@ -0,0 +1,35 @@
+:root {
+ font-family: "Inter", "Segoe UI", Helvetica, Arial, sans-serif;
+ line-height: 1.4;
+ font-weight: 400;
+ color: #0f172a;
+ background-color: #f1f5f9;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ height: 100vh; /* fallback for browsers without dvh */
+ height: 100dvh; /* tracks the visible area as mobile browser chrome changes */
+ overflow: hidden; /* the document itself never scrolls; panels scroll internally */
+ background-color: inherit;
+ color: inherit;
+}
+
+a {
+ color: inherit;
+}
+
+#root {
+ height: 100%;
+}
diff --git a/apps/probe-viewer/src/main.tsx b/apps/probe-viewer/src/main.tsx
new file mode 100644
index 0000000..4b6c117
--- /dev/null
+++ b/apps/probe-viewer/src/main.tsx
@@ -0,0 +1,38 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { createHashRouter, RouterProvider } from "react-router-dom";
+
+import App from "./App.tsx";
+import "./index.css";
+
+// =============================================================================
+// Hash Router for GitHub Pages
+// =============================================================================
+//
+// We use hash-based routing (URLs like /#/probes/imec/NP1000) instead of
+// browser-based routing (/probes/imec/NP1000) because:
+//
+// 1. GitHub Pages is a static file server - it can only serve files that exist
+// 2. With browser routing, /probes/imec/NP1000 returns 404 (no such file)
+// 3. Hash fragments (#...) are never sent to the server - the browser handles them
+// 4. So /#/probes/imec/NP1000 requests /, server returns index.html, React handles the rest
+//
+// Trade-off: URLs are slightly uglier, but direct links and refresh work perfectly.
+// =============================================================================
+
+const router = createHashRouter([
+ {
+ path: "/",
+ element: ,
+ },
+ {
+ path: "/probes/:manufacturer/:model",
+ element: ,
+ },
+]);
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/apps/probe-viewer/src/services/manifest.ts b/apps/probe-viewer/src/services/manifest.ts
new file mode 100644
index 0000000..b80864d
--- /dev/null
+++ b/apps/probe-viewer/src/services/manifest.ts
@@ -0,0 +1,30 @@
+import type { ManifestEntry, RawManifestEntry } from "../types/probe";
+
+const MANIFEST_URL = `${import.meta.env.BASE_URL}probes-manifest.json`;
+
+function normalizeEntry(raw: RawManifestEntry): ManifestEntry {
+ return {
+ id: raw.id,
+ manufacturer: raw.manufacturer,
+ model: raw.model,
+ displayName: raw.display_name,
+ jsonUrl: `${import.meta.env.BASE_URL}${raw.json_url}`,
+ contactCount: raw.contact_count,
+ shankCount: raw.shank_count,
+ has3dGeometry: raw.has_3d_geometry,
+ annotations: raw.annotations ?? {},
+};
+}
+
+export async function fetchManifest(): Promise {
+ const response = await fetch(MANIFEST_URL, {
+ headers: { "Content-Type": "application/json" },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to load manifest (${response.status})`);
+ }
+
+ const data: RawManifestEntry[] = await response.json();
+ return data.map(normalizeEntry);
+}
diff --git a/apps/probe-viewer/src/services/probeLoader.ts b/apps/probe-viewer/src/services/probeLoader.ts
new file mode 100644
index 0000000..71942de
--- /dev/null
+++ b/apps/probe-viewer/src/services/probeLoader.ts
@@ -0,0 +1,18 @@
+import type { ManifestEntry, ProbeInterfaceFile } from "../types/probe";
+
+export async function fetchProbeData(
+ entry: ManifestEntry,
+): Promise {
+ const response = await fetch(entry.jsonUrl, {
+ headers: { "Content-Type": "application/json" },
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to load probe ${entry.id} (${response.statusText})`,
+ );
+ }
+
+ const data: ProbeInterfaceFile = await response.json();
+ return data;
+}
diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts
new file mode 100644
index 0000000..0a37206
--- /dev/null
+++ b/apps/probe-viewer/src/state/useAppStore.ts
@@ -0,0 +1,223 @@
+import { create } from "zustand";
+
+import { fetchManifest } from "../services/manifest";
+import { fetchProbeData } from "../services/probeLoader";
+import type { ManifestEntry, ProbeInterfaceFile } from "../types/probe";
+
+type LoadStatus = "idle" | "loading" | "success" | "error";
+
+interface ProbeLoadState {
+ status: LoadStatus;
+ error?: string;
+}
+
+interface ViewState {
+ zoom: number;
+ viewCenterX: number | null; // null = centered on geometry center
+ viewCenterY: number | null; // in probe coordinates (micrometers)
+ showContactIds: boolean;
+ showScaleBar: boolean;
+ showOverview: boolean;
+}
+
+interface AppState {
+ manifest: ManifestEntry[];
+ manifestStatus: LoadStatus;
+ manifestError?: string;
+ selectedManufacturer?: string;
+ selectedProbeId?: string;
+ searchQuery: string;
+ probeCache: Record;
+ probeStatus: Record;
+ view: ViewState;
+
+ loadManifest: () => Promise;
+ selectManufacturer: (manufacturer?: string) => void;
+ setSearchQuery: (query: string) => void;
+ selectProbe: (probeId?: string) => void;
+ ensureProbeLoaded: (probeId: string) => Promise;
+ setZoom: (zoom: number) => void;
+ setViewCenter: (x: number | null, y: number | null) => void;
+ resetView: () => void;
+ toggleContactIds: (value?: boolean) => void;
+ toggleScaleBar: (value?: boolean) => void;
+ toggleOverview: (value?: boolean) => void;
+}
+
+export const VIEW_ZOOM_MIN = 0.1;
+export const VIEW_ZOOM_MAX = 100; // High max for long probes like Neuropixels
+
+const INITIAL_VIEW_STATE: ViewState = {
+ zoom: 1,
+ viewCenterX: null,
+ viewCenterY: null,
+ showContactIds: false,
+ showScaleBar: true,
+ showOverview: true,
+};
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
+
+export const useAppStore = create((set, get) => ({
+ manifest: [],
+ manifestStatus: "idle",
+ manifestError: undefined,
+ selectedManufacturer: undefined,
+ selectedProbeId: undefined,
+ searchQuery: "",
+ probeCache: {},
+ probeStatus: {},
+ view: INITIAL_VIEW_STATE,
+
+ loadManifest: async () => {
+ const { manifestStatus } = get();
+ if (manifestStatus === "loading" || manifestStatus === "success") {
+ return;
+ }
+
+ set({ manifestStatus: "loading", manifestError: undefined });
+ try {
+ const manifest = await fetchManifest();
+ set((state) => {
+ const nextManufacturer =
+ state.selectedManufacturer ?? manifest[0]?.manufacturer;
+ return {
+ manifest,
+ manifestStatus: "success" as const,
+ selectedManufacturer: nextManufacturer,
+ };
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Unknown manifest error";
+ set({
+ manifestStatus: "error",
+ manifestError: message,
+ });
+ }
+ },
+
+ selectManufacturer: (manufacturer) => set({ selectedManufacturer: manufacturer }),
+
+ setSearchQuery: (query) => set({ searchQuery: query }),
+
+ selectProbe: (probeId) =>
+ set((state) => {
+ if (!probeId) {
+ return { selectedProbeId: undefined };
+ }
+ const entry = state.manifest.find((item) => item.id === probeId);
+ return {
+ selectedProbeId: probeId,
+ selectedManufacturer: entry?.manufacturer ?? state.selectedManufacturer,
+ };
+ }),
+
+ ensureProbeLoaded: async (probeId) => {
+ const { probeCache, probeStatus, manifest } = get();
+ if (probeCache[probeId]) {
+ return probeCache[probeId];
+ }
+
+ const existingStatus = probeStatus[probeId];
+ if (existingStatus?.status === "loading") {
+ return undefined;
+ }
+
+ const entry = manifest.find((item) => item.id === probeId);
+ if (!entry) {
+ set((state) => ({
+ probeStatus: {
+ ...state.probeStatus,
+ [probeId]: { status: "error", error: "Unknown probe" },
+ },
+ }));
+ return undefined;
+ }
+
+ set((state) => ({
+ probeStatus: {
+ ...state.probeStatus,
+ [probeId]: { status: "loading" },
+ },
+ }));
+
+ try {
+ const data = await fetchProbeData(entry);
+ set((state) => ({
+ probeCache: { ...state.probeCache, [probeId]: data },
+ probeStatus: {
+ ...state.probeStatus,
+ [probeId]: { status: "success" },
+ },
+ }));
+ return data;
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to load probe data";
+ set((state) => ({
+ probeStatus: {
+ ...state.probeStatus,
+ [probeId]: { status: "error", error: message },
+ },
+ }));
+ return undefined;
+ }
+ },
+
+ setZoom: (zoom) =>
+ set((state) => ({
+ view: {
+ ...state.view,
+ zoom: clamp(zoom, VIEW_ZOOM_MIN, VIEW_ZOOM_MAX),
+ },
+ })),
+
+ setViewCenter: (x, y) =>
+ set((state) => ({
+ view: {
+ ...state.view,
+ viewCenterX: x,
+ viewCenterY: y,
+ },
+ })),
+
+ resetView: () =>
+ set((state) => ({
+ view: {
+ ...INITIAL_VIEW_STATE,
+ showContactIds: state.view.showContactIds,
+ },
+ })),
+
+ toggleContactIds: (value) =>
+ set((state) => ({
+ view: {
+ ...state.view,
+ showContactIds:
+ value !== undefined ? value : !state.view.showContactIds,
+ },
+ })),
+
+ toggleScaleBar: (value) =>
+ set((state) => ({
+ view: {
+ ...state.view,
+ showScaleBar:
+ value !== undefined ? value : !state.view.showScaleBar,
+ },
+ })),
+
+ toggleOverview: (value) =>
+ set((state) => ({
+ view: {
+ ...state.view,
+ showOverview:
+ value !== undefined ? value : !state.view.showOverview,
+ },
+ })),
+}));
+
+export type { AppState, LoadStatus, ManifestEntry, ProbeInterfaceFile };
diff --git a/apps/probe-viewer/src/types/probe.ts b/apps/probe-viewer/src/types/probe.ts
new file mode 100644
index 0000000..efe6033
--- /dev/null
+++ b/apps/probe-viewer/src/types/probe.ts
@@ -0,0 +1,47 @@
+export interface RawManifestEntry {
+ id: string;
+ manufacturer: string;
+ model: string;
+ display_name: string;
+ json_url: string;
+ contact_count: number;
+ shank_count: number;
+ has_3d_geometry: boolean;
+ annotations: Record;
+}
+
+export interface ManifestEntry {
+ id: string;
+ manufacturer: string;
+ model: string;
+ displayName: string;
+ jsonUrl: string;
+ contactCount: number;
+ shankCount: number;
+ has3dGeometry: boolean;
+ annotations: Record;
+}
+
+export interface ContactShapeParams {
+ radius?: number; // for circle
+ width?: number; // for square and rect
+ height?: number; // for rect
+}
+
+export interface ProbeInterfaceProbe {
+ ndim: number;
+ si_units: string;
+ annotations?: Record;
+ contact_positions: number[][];
+ contact_shapes?: string[]; // "circle" | "square" | "rect"
+ contact_shape_params?: ContactShapeParams[];
+ contact_ids?: (string | number)[];
+ shank_ids?: number[];
+ probe_planar_contour?: number[][];
+}
+
+export interface ProbeInterfaceFile {
+ specification: string;
+ version: string;
+ probes: ProbeInterfaceProbe[];
+}
diff --git a/apps/probe-viewer/src/utils/exportUtils.ts b/apps/probe-viewer/src/utils/exportUtils.ts
new file mode 100644
index 0000000..d071d4f
--- /dev/null
+++ b/apps/probe-viewer/src/utils/exportUtils.ts
@@ -0,0 +1,482 @@
+import type { ProbeInterfaceFile, ContactShapeParams } from "../types/probe";
+
+interface ExportViewState {
+ zoom: number;
+ viewCenterX: number | null;
+ viewCenterY: number | null;
+}
+
+interface CanvasSize {
+ width: number;
+ height: number;
+}
+
+interface GeometrySummary {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ width: number;
+ height: number;
+ centerX: number;
+ centerY: number;
+}
+
+function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary | null {
+ const probe = probeData.probes?.[0];
+ if (!probe) {
+ return null;
+ }
+
+ const positions = probe.contact_positions ?? [];
+ if (positions.length === 0) {
+ return null;
+ }
+
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+
+ const updateBounds = (point: number[]) => {
+ const [x, y] = point;
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ };
+
+ positions.forEach(updateBounds);
+ (probe.probe_planar_contour ?? []).forEach(updateBounds);
+
+ const width = Math.max(10, maxX - minX);
+ const height = Math.max(10, maxY - minY);
+ const centerX = minX + width / 2;
+ const centerY = minY + height / 2;
+
+ return { minX, maxX, minY, maxY, width, height, centerX, centerY };
+}
+
+/**
+ * Export probe visualization as PNG with white background.
+ * Re-renders the probe without contact IDs. Scale bar included if enabled.
+ */
+export function exportProbeAsPng(
+ probeData: ProbeInterfaceFile,
+ viewState: ExportViewState,
+ canvasSize: CanvasSize,
+ filename: string,
+ showScaleBar: boolean
+): void {
+ const canvas = document.createElement("canvas");
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = canvasSize.width * dpr;
+ canvas.height = canvasSize.height * dpr;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ // White background
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
+
+ // Render probe (no contact IDs, scale bar if enabled)
+ renderProbeToContext(ctx, probeData, viewState, canvasSize, showScaleBar);
+
+ // Download
+ const link = document.createElement("a");
+ link.download = filename;
+ link.href = canvas.toDataURL("image/png");
+ link.click();
+}
+
+/**
+ * Export probe visualization as SVG with transparent background.
+ * Re-renders the probe without contact IDs. Scale bar included if enabled.
+ */
+export function exportProbeAsSvg(
+ probeData: ProbeInterfaceFile,
+ viewState: ExportViewState,
+ canvasSize: CanvasSize,
+ filename: string,
+ showScaleBar: boolean
+): void {
+ const svgString = generateProbeSvgString(probeData, viewState, canvasSize, showScaleBar);
+ const blob = new Blob([svgString], { type: "image/svg+xml" });
+ const link = document.createElement("a");
+ link.download = filename;
+ link.href = URL.createObjectURL(blob);
+ link.click();
+ URL.revokeObjectURL(link.href);
+}
+
+/**
+ * Render probe to a 2D canvas context (used for PNG export).
+ * Mirrors the ProbeCanvas rendering logic but without contact IDs.
+ */
+function renderProbeToContext(
+ ctx: CanvasRenderingContext2D,
+ probeData: ProbeInterfaceFile,
+ viewState: ExportViewState,
+ canvasSize: CanvasSize,
+ showScaleBar: boolean
+): void {
+ const geometry = computeGeometrySummary(probeData);
+ const probe = probeData.probes?.[0];
+ if (!geometry || !probe) return;
+
+ const { zoom, viewCenterX, viewCenterY } = viewState;
+ const { width: widthPx, height: heightPx } = canvasSize;
+
+ // Calculate effective view center (use geometry center if null)
+ const effectiveViewCenterX = viewCenterX ?? geometry.centerX;
+ const effectiveViewCenterY = viewCenterY ?? geometry.centerY;
+
+ const padding = 40;
+ const availableWidth = Math.max(10, widthPx - padding * 2);
+ const availableHeight = Math.max(10, heightPx - padding * 2);
+ const baseScale = Math.min(
+ availableWidth / geometry.width,
+ availableHeight / geometry.height
+ );
+ const scale = baseScale * zoom;
+
+ // Calculate pixel pan from view center
+ const panX = (geometry.centerX - effectiveViewCenterX) * scale;
+ const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+
+ const offsetX = widthPx / 2 + panX;
+ const offsetY = heightPx / 2 + panY;
+
+ const projectPoint = (point: number[]) => {
+ const [x, y] = point;
+ const normX = (x - geometry.centerX) * scale + offsetX;
+ const normY = -(y - geometry.centerY) * scale + offsetY;
+ return [normX, normY];
+ };
+
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+
+ // Draw probe contour
+ if (probe.probe_planar_contour && probe.probe_planar_contour.length > 1) {
+ ctx.beginPath();
+ probe.probe_planar_contour.forEach((point, index) => {
+ const [x, y] = projectPoint(point);
+ if (index === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ });
+ ctx.closePath();
+ ctx.fillStyle = "rgba(180, 185, 195, 0.7)";
+ ctx.strokeStyle = "rgba(100, 105, 115, 0.95)";
+ ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 100));
+ ctx.fill();
+ ctx.stroke();
+ }
+
+ const contactPositions = probe.contact_positions ?? [];
+ const contactShapes = probe.contact_shapes ?? [];
+ const contactShapeParams = probe.contact_shape_params ?? [];
+
+ const drawContactShape = (
+ x: number,
+ y: number,
+ shape: string,
+ params: ContactShapeParams
+ ) => {
+ ctx.beginPath();
+ switch (shape) {
+ case "circle": {
+ const radius = (params.radius ?? 5) * scale;
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ break;
+ }
+ case "square": {
+ const side = (params.width ?? 10) * scale;
+ ctx.rect(x - side / 2, y - side / 2, side, side);
+ break;
+ }
+ case "rect": {
+ const w = (params.width ?? 10) * scale;
+ const h = (params.height ?? 15) * scale;
+ ctx.rect(x - w / 2, y - h / 2, w, h);
+ break;
+ }
+ default: {
+ const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100)));
+ ctx.arc(x, y, markerSize * 0.4, 0, Math.PI * 2);
+ ctx.closePath();
+ ctx.moveTo(x - markerSize, y - markerSize);
+ ctx.lineTo(x + markerSize, y + markerSize);
+ ctx.moveTo(x + markerSize, y - markerSize);
+ ctx.lineTo(x - markerSize, y + markerSize);
+ }
+ }
+ };
+
+ // Shadow offset for depth effect - subtle, proportional to scale
+ const shadowOffset = 0.4 * scale; // 0.4 micrometer offset for subtle depth
+
+ // First pass: draw shadows
+ contactPositions.forEach((position, index) => {
+ const [x, y] = projectPoint(position);
+ const shape = contactShapes[index] ?? "";
+ const params = contactShapeParams[index] ?? {};
+
+ drawContactShape(x + shadowOffset, y + shadowOffset, shape, params);
+ ctx.fillStyle = "rgba(30, 20, 5, 0.7)";
+ ctx.fill();
+ });
+
+ // Second pass: draw gold contacts
+ contactPositions.forEach((position, index) => {
+ const [x, y] = projectPoint(position);
+ const shape = contactShapes[index] ?? "";
+ const params = contactShapeParams[index] ?? {};
+
+ drawContactShape(x, y, shape, params);
+
+ ctx.fillStyle = "rgba(212, 175, 55, 1.0)"; // Fully opaque to cover shadow
+ ctx.strokeStyle = "rgba(80, 60, 15, 0.9)";
+ ctx.lineWidth = Math.max(1.2, 2.5 * (scale / 150));
+ ctx.fill();
+ ctx.stroke();
+ });
+
+ // Scale bar (L-shaped, bottom-left corner)
+ if (showScaleBar) {
+ const niceNumbers = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
+ const targetPixels = 80;
+ const targetUm = targetPixels / scale;
+ const scaleBarUm = niceNumbers.reduce((prev, curr) =>
+ Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev
+ );
+ const scaleBarPixels = scaleBarUm * scale;
+
+ const margin = 20;
+ const cornerX = margin;
+ const cornerY = heightPx - margin;
+ const tickSize = 4;
+
+ ctx.strokeStyle = "rgba(15, 23, 42, 0.9)";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "square";
+
+ // Draw L shape
+ ctx.beginPath();
+ ctx.moveTo(cornerX, cornerY);
+ ctx.lineTo(cornerX, cornerY - scaleBarPixels);
+ ctx.moveTo(cornerX, cornerY);
+ ctx.lineTo(cornerX + scaleBarPixels, cornerY);
+ ctx.stroke();
+
+ // End ticks
+ ctx.beginPath();
+ ctx.moveTo(cornerX - tickSize, cornerY - scaleBarPixels);
+ ctx.lineTo(cornerX + tickSize, cornerY - scaleBarPixels);
+ ctx.moveTo(cornerX + scaleBarPixels, cornerY - tickSize);
+ ctx.lineTo(cornerX + scaleBarPixels, cornerY + tickSize);
+ ctx.stroke();
+
+ // Labels
+ const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`;
+ ctx.font = '11px "Inter", sans-serif';
+ ctx.fillStyle = "rgba(15, 23, 42, 0.9)";
+
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ ctx.fillText(label, cornerX + scaleBarPixels / 2, cornerY + 5);
+
+ ctx.save();
+ ctx.translate(cornerX - 6, cornerY - scaleBarPixels / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.textAlign = "center";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(label, 0, 0);
+ ctx.restore();
+ }
+}
+
+/**
+ * Generate SVG string for probe visualization.
+ * Transparent background, no contact IDs. Scale bar included if enabled.
+ * Contacts outside the current frame are omitted, so the export matches what is
+ * on screen and stays small even when zoomed into a long probe.
+ */
+function generateProbeSvgString(
+ probeData: ProbeInterfaceFile,
+ viewState: ExportViewState,
+ canvasSize: CanvasSize,
+ showScaleBar: boolean
+): string {
+ const geometry = computeGeometrySummary(probeData);
+ const probe = probeData.probes?.[0];
+
+ if (!geometry || !probe) {
+ return ` `;
+ }
+
+ const { zoom, viewCenterX, viewCenterY } = viewState;
+ const { width: widthPx, height: heightPx } = canvasSize;
+
+ // Calculate effective view center (use geometry center if null)
+ const effectiveViewCenterX = viewCenterX ?? geometry.centerX;
+ const effectiveViewCenterY = viewCenterY ?? geometry.centerY;
+
+ const padding = 40;
+ const availableWidth = Math.max(10, widthPx - padding * 2);
+ const availableHeight = Math.max(10, heightPx - padding * 2);
+ const baseScale = Math.min(
+ availableWidth / geometry.width,
+ availableHeight / geometry.height
+ );
+ const scale = baseScale * zoom;
+
+ // Calculate pixel pan from view center
+ const panX = (geometry.centerX - effectiveViewCenterX) * scale;
+ const panY = (effectiveViewCenterY - geometry.centerY) * scale;
+
+ const offsetX = widthPx / 2 + panX;
+ const offsetY = heightPx / 2 + panY;
+
+ const projectPoint = (point: number[]): [number, number] => {
+ const [x, y] = point;
+ const normX = (x - geometry.centerX) * scale + offsetX;
+ const normY = -(y - geometry.centerY) * scale + offsetY;
+ return [normX, normY];
+ };
+
+ const elements: string[] = [];
+
+ // Probe contour
+ if (probe.probe_planar_contour && probe.probe_planar_contour.length > 1) {
+ const points = probe.probe_planar_contour
+ .map((p) => projectPoint(p).join(","))
+ .join(" ");
+ const strokeWidth = Math.max(1.2, 2.5 * (scale / 100));
+ elements.push(
+ ` `
+ );
+ }
+
+ const contactPositions = probe.contact_positions ?? [];
+ const contactShapes = probe.contact_shapes ?? [];
+ const contactShapeParams = probe.contact_shape_params ?? [];
+ const shadowOffset = 0.4 * scale; // 0.4 micrometer offset for subtle depth
+ const contactStrokeWidth = Math.max(1.2, 2.5 * (scale / 150));
+
+ // Helper to generate contact SVG element
+ const generateContactSvg = (
+ x: number,
+ y: number,
+ shape: string,
+ params: ContactShapeParams,
+ isShadow: boolean
+ ): string => {
+ const fill = isShadow ? "rgba(30, 20, 5, 0.7)" : "rgba(212, 175, 55, 1.0)";
+ const stroke = isShadow ? "none" : "rgba(80, 60, 15, 0.9)";
+ const sw = isShadow ? 0 : contactStrokeWidth;
+
+ switch (shape) {
+ case "circle": {
+ const radius = (params.radius ?? 5) * scale;
+ return ` `;
+ }
+ case "square": {
+ const side = (params.width ?? 10) * scale;
+ return ` `;
+ }
+ case "rect": {
+ const w = (params.width ?? 10) * scale;
+ const h = (params.height ?? 15) * scale;
+ return ` `;
+ }
+ default: {
+ // Unknown shape: small circle
+ const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100)));
+ return ` `;
+ }
+ }
+ };
+
+ // Only emit contacts whose drawn body reaches the frame, so the export matches
+ // what is on screen instead of carrying hundreds of off-screen contacts.
+ const maxContactSizeUm = contactShapeParams.reduce((max, p) => {
+ const size = Math.max((p.radius ?? 0) * 2, p.width ?? 0, p.height ?? 0);
+ return Math.max(max, size);
+ }, 10);
+ const frameMargin = maxContactSizeUm * scale + shadowOffset;
+ const isContactInFrame = (x: number, y: number) =>
+ x >= -frameMargin &&
+ x <= widthPx + frameMargin &&
+ y >= -frameMargin &&
+ y <= heightPx + frameMargin;
+
+ // First pass: shadows
+ contactPositions.forEach((position, index) => {
+ const [x, y] = projectPoint(position);
+ if (!isContactInFrame(x, y)) return;
+ const shape = contactShapes[index] ?? "";
+ const params = contactShapeParams[index] ?? {};
+ elements.push(
+ generateContactSvg(x + shadowOffset, y + shadowOffset, shape, params, true)
+ );
+ });
+
+ // Second pass: gold contacts
+ contactPositions.forEach((position, index) => {
+ const [x, y] = projectPoint(position);
+ if (!isContactInFrame(x, y)) return;
+ const shape = contactShapes[index] ?? "";
+ const params = contactShapeParams[index] ?? {};
+ elements.push(generateContactSvg(x, y, shape, params, false));
+ });
+
+ // Scale bar (L-shaped, bottom-left corner)
+ if (showScaleBar) {
+ const niceNumbers = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
+ const targetPixels = 80;
+ const targetUm = targetPixels / scale;
+ const scaleBarUm = niceNumbers.reduce((prev, curr) =>
+ Math.abs(curr - targetUm) < Math.abs(prev - targetUm) ? curr : prev
+ );
+ const scaleBarPixels = scaleBarUm * scale;
+
+ const margin = 20;
+ const cornerX = margin;
+ const cornerY = heightPx - margin;
+ const tickSize = 4;
+
+ const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`;
+ const strokeStyle = "rgba(15, 23, 42, 0.9)";
+
+ // L shape path
+ elements.push(
+ ` `
+ );
+
+ // End ticks
+ elements.push(
+ ` `
+ );
+
+ // X label (below horizontal arm)
+ elements.push(
+ `${label} `
+ );
+
+ // Y label (rotated, to the left of vertical arm)
+ elements.push(
+ `${label} `
+ );
+ }
+
+ return `
+${elements.join("\n")}
+ `;
+}
diff --git a/apps/probe-viewer/tsconfig.app.json b/apps/probe-viewer/tsconfig.app.json
new file mode 100644
index 0000000..a9b5a59
--- /dev/null
+++ b/apps/probe-viewer/tsconfig.app.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/probe-viewer/tsconfig.json b/apps/probe-viewer/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/apps/probe-viewer/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/apps/probe-viewer/tsconfig.node.json b/apps/probe-viewer/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/apps/probe-viewer/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/probe-viewer/vite.config.ts b/apps/probe-viewer/vite.config.ts
new file mode 100644
index 0000000..c7aa51d
--- /dev/null
+++ b/apps/probe-viewer/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ base: '/probeinterface_library/',
+})
diff --git a/tests.py b/tests.py
index b9a1aeb..c078064 100644
--- a/tests.py
+++ b/tests.py
@@ -10,7 +10,14 @@
response = requests.get(schema_url)
response.raise_for_status()
-files = glob.glob("*/*/*.json")
+# Probe files live at //.json. Exclude directories
+# that are not probe data (the probe-viewer app ships its own JSON config files).
+NON_PROBE_DIRS = {"apps", "scripts", "node_modules", ".github"}
+files = [
+ file
+ for file in glob.glob("*/*/*.json")
+ if file.split("/")[0] not in NON_PROBE_DIRS
+]
@pytest.mark.parametrize("file", files)