From b5d2ef6b6ebc0fc71f70b7bb04cc32e8e05dc824 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Sun, 7 Jun 2026 21:12:39 -0600 Subject: [PATCH 1/6] Add probe visulalization app --- .github/workflows/deploy.yml | 60 + .gitignore | 15 + apps/probe-viewer/README.md | 198 + apps/probe-viewer/build.py | 247 ++ apps/probe-viewer/eslint.config.js | 23 + apps/probe-viewer/index.html | 13 + apps/probe-viewer/package-lock.json | 3590 +++++++++++++++++ apps/probe-viewer/package.json | 33 + apps/probe-viewer/public/.gitkeep | 0 apps/probe-viewer/src/App.css | 439 ++ apps/probe-viewer/src/App.tsx | 120 + apps/probe-viewer/src/assets/react.svg | 1 + apps/probe-viewer/src/components/JsonTree.tsx | 126 + .../src/components/ProbeCanvas.tsx | 448 ++ .../src/components/ProbeOverview.tsx | 253 ++ .../src/components/ProbeViewer.tsx | 332 ++ apps/probe-viewer/src/components/Sidebar.tsx | 125 + .../src/hooks/useResizeObserver.ts | 28 + apps/probe-viewer/src/index.css | 33 + apps/probe-viewer/src/main.tsx | 38 + apps/probe-viewer/src/services/manifest.ts | 30 + apps/probe-viewer/src/services/probeLoader.ts | 18 + apps/probe-viewer/src/state/useAppStore.ts | 223 + apps/probe-viewer/src/types/probe.ts | 46 + apps/probe-viewer/src/utils/exportUtils.ts | 353 ++ apps/probe-viewer/tsconfig.app.json | 28 + apps/probe-viewer/tsconfig.json | 7 + apps/probe-viewer/tsconfig.node.json | 26 + apps/probe-viewer/vite.config.ts | 8 + 29 files changed, 6861 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 apps/probe-viewer/README.md create mode 100755 apps/probe-viewer/build.py create mode 100644 apps/probe-viewer/eslint.config.js create mode 100644 apps/probe-viewer/index.html create mode 100644 apps/probe-viewer/package-lock.json create mode 100644 apps/probe-viewer/package.json create mode 100644 apps/probe-viewer/public/.gitkeep create mode 100644 apps/probe-viewer/src/App.css create mode 100644 apps/probe-viewer/src/App.tsx create mode 100644 apps/probe-viewer/src/assets/react.svg create mode 100644 apps/probe-viewer/src/components/JsonTree.tsx create mode 100644 apps/probe-viewer/src/components/ProbeCanvas.tsx create mode 100644 apps/probe-viewer/src/components/ProbeOverview.tsx create mode 100644 apps/probe-viewer/src/components/ProbeViewer.tsx create mode 100644 apps/probe-viewer/src/components/Sidebar.tsx create mode 100644 apps/probe-viewer/src/hooks/useResizeObserver.ts create mode 100644 apps/probe-viewer/src/index.css create mode 100644 apps/probe-viewer/src/main.tsx create mode 100644 apps/probe-viewer/src/services/manifest.ts create mode 100644 apps/probe-viewer/src/services/probeLoader.ts create mode 100644 apps/probe-viewer/src/state/useAppStore.ts create mode 100644 apps/probe-viewer/src/types/probe.ts create mode 100644 apps/probe-viewer/src/utils/exportUtils.ts create mode 100644 apps/probe-viewer/tsconfig.app.json create mode 100644 apps/probe-viewer/tsconfig.json create mode 100644 apps/probe-viewer/tsconfig.node.json create mode 100644 apps/probe-viewer/vite.config.ts 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..b692fdb --- /dev/null +++ b/apps/probe-viewer/src/App.css @@ -0,0 +1,439 @@ +.app-shell { + display: flex; + min-height: 100vh; + 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; + 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; + 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: 1.5rem; + 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; +} + +.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 { + 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 { + 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-json-panel { + background: rgba(248, 250, 252, 0.95); + border-radius: 1rem; + padding: 1.25rem; + border: 1px solid rgba(148, 163, 184, 0.3); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.viewer-json-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.viewer-json-header h3 { + margin: 0; + font-size: 1rem; +} + +.viewer-json-meta { + font-size: 0.85rem; + color: #475569; +} + +.viewer-json { + margin: 0; + padding: 1rem; + background: #0f172a; + color: #e2e8f0; + border-radius: 0.75rem; + font-size: 0.85rem; + overflow: auto; + max-height: 360px; + font-family: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.json-tree-node > summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + color: #cbd5f5; +} + +.json-tree-node > summary:hover { + color: #94a3ff; +} + +.json-tree-node > summary::marker, +.json-tree-node > summary::-webkit-details-marker { + display: none; +} + +.json-tree-node > summary::before { + content: "▸"; + display: inline-block; + margin-right: 0.35rem; + transition: transform 0.15s ease; +} + +.json-tree-node[open] > summary::before { + transform: rotate(90deg); +} + +.json-tree-key { + font-weight: 600; + color: #f8fafc; +} + +.json-tree-meta { + font-size: 0.75rem; + color: rgba(226, 232, 240, 0.7); +} + +.json-tree-children { + margin-left: 1rem; + border-left: 1px solid rgba(148, 163, 184, 0.3); + padding-left: 0.75rem; + margin-top: 0.4rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.json-tree-item { + margin-left: 1.35rem; + color: #e2e8f0; + display: flex; + align-items: baseline; + gap: 0.4rem; +} + +.json-tree-value--primitive { + color: #facc15; +} + +.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..1f74b12 --- /dev/null +++ b/apps/probe-viewer/src/App.tsx @@ -0,0 +1,120 @@ +import { useEffect, useMemo } from "react"; +import { + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; + +import { ProbeViewer } from "./components/ProbeViewer"; +import { Sidebar } from "./components/Sidebar"; +import { useAppStore } from "./state/useAppStore"; +import "./App.css"; + +function App() { + const { manufacturer, model } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + + 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); + + useEffect(() => { + void loadManifest(); + }, [loadManifest]); + + 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; + + if (selectedProbeId && !currentSelected) { + const fallback = routeEntry ?? manifest[0]; + if (fallback && fallback.id !== selectedProbeId) { + selectProbe(fallback.id); + } + return; + } + + if (!selectedProbeId) { + if (routeEntry) { + selectProbe(routeEntry.id); + } else { + const fallback = manifest[0]; + 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/JsonTree.tsx b/apps/probe-viewer/src/components/JsonTree.tsx new file mode 100644 index 0000000..8b97d49 --- /dev/null +++ b/apps/probe-viewer/src/components/JsonTree.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; + +interface JsonTreeProps { + data: unknown; + name?: string; + path?: string; + depth?: number; + defaultExpanded?: boolean; +} + +function formatPrimitive(value: unknown): string { + if (typeof value === "string") { + return `"${value}"`; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value === null) { + return "null"; + } + if (typeof value === "undefined") { + return "undefined"; + } + return JSON.stringify(value); +} + +export function JsonTree({ + data, + name, + path = "root", + depth = 0, + defaultExpanded = depth === 0, +}: JsonTreeProps) { + const isArray = Array.isArray(data); + const isPlainObject = + data !== null && + typeof data === "object" && + !isArray; + + if (!isArray && !isPlainObject) { + return ( +
+ {name !== undefined && {name}: } + + {formatPrimitive(data)} + +
+ ); + } + + return ( + + ); +} + +interface JsonTreeBranchProps { + data: unknown; + name?: string; + path: string; + depth: number; + defaultExpanded: boolean; +} + +function JsonTreeBranch({ + data, + name, + path, + depth, + defaultExpanded, +}: JsonTreeBranchProps) { + const isArray = Array.isArray(data); + const entries = isArray + ? (data as unknown[]).map((value, index) => ({ + key: String(index), + value, + label: `[${index}]`, + })) + : Object.entries(data as Record).map(([key, value]) => ({ + key, + value, + label: key, + })); + + const summaryMeta = isArray + ? `[${entries.length}]` + : `{${entries.length}}`; + + const nodeName = name ?? (isArray ? "Array" : "Object"); + + const [isOpen, setIsOpen] = useState(defaultExpanded); + + useEffect(() => { + setIsOpen(defaultExpanded); + }, [defaultExpanded, path]); + + return ( +
setIsOpen(event.currentTarget.open)} + > + + {nodeName} + {summaryMeta} + +
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx new file mode 100644 index 0000000..b33439a --- /dev/null +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -0,0 +1,448 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, + WheelEvent as ReactWheelEvent, +} 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; + panX: number; + panY: number; + showContactIds: boolean; + showScaleBar: boolean; + onPan: (x: number, y: number) => 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, + panX, + panY, + showContactIds, + showScaleBar, + onPan, + 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; panX: number; panY: number } | null>(null); + + const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]); + const probe = useMemo(() => probeData.probes?.[0], [probeData]); + + 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; + canvas.width = widthPx * devicePixelRatio; + canvas.height = heightPx * devicePixelRatio; + canvas.style.width = `${widthPx}px`; + canvas.style.height = `${heightPx}px`; + 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; + + 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) { + 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); + ctx.fillText(String(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, geometry, panX, panY, 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)), + [], + ); + + const handleWheel = useCallback( + (event: ReactWheelEvent) => { + event.preventDefault(); + + // Get mouse 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 centerX = rect.width / 2; + const centerY = rect.height / 2; + + // Mouse offset from center (in screen pixels) + const offsetFromCenterX = mouseX - centerX; + const offsetFromCenterY = mouseY - centerY; + + // Calculate new zoom + const zoomFactor = Math.exp(-event.deltaY * 0.002); + const nextZoom = clampZoom(zoom * zoomFactor); + const actualZoomFactor = nextZoom / zoom; + + // Adjust pan so the point under the mouse stays fixed + // The point under mouse in current view: (panX + offsetFromCenterX, panY + offsetFromCenterY) + // After zoom, we want the same world point under mouse, so: + // newPanX + offsetFromCenterX = (panX + offsetFromCenterX) * actualZoomFactor + // newPanX = panX * actualZoomFactor + offsetFromCenterX * (actualZoomFactor - 1) + const newPanX = panX * actualZoomFactor + offsetFromCenterX * (actualZoomFactor - 1); + const newPanY = panY * actualZoomFactor + offsetFromCenterY * (actualZoomFactor - 1); + + onPan(newPanX, newPanY); + onZoom(nextZoom); + }, + [clampZoom, onPan, onZoom, panX, panY, zoom], + ); + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + event.preventDefault(); + setIsDragging(true); + dragOriginRef.current = { + x: event.clientX, + y: event.clientY, + panX, + panY, + }; + (event.target as HTMLCanvasElement).setPointerCapture(event.pointerId); + }, [panX, panY]); + + 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; + onPan(dragOriginRef.current.panX + deltaX, dragOriginRef.current.panY + deltaY); + }, [isDragging, onPan]); + + const handlePointerUp = useCallback((event: ReactPointerEvent) => { + if (isDragging) { + event.preventDefault(); + setIsDragging(false); + dragOriginRef.current = null; + (event.target as HTMLCanvasElement).releasePointerCapture(event.pointerId); + } + }, [isDragging]); + + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + + // 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 centerX = rect.width / 2; + const centerY = rect.height / 2; + + // Mouse offset from center + const offsetFromCenterX = mouseX - centerX; + const offsetFromCenterY = mouseY - centerY; + + // 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 + const newPanX = panX * actualZoomFactor + offsetFromCenterX * (actualZoomFactor - 1); + const newPanY = panY * actualZoomFactor + offsetFromCenterY * (actualZoomFactor - 1); + + onPan(newPanX, newPanY); + onZoom(nextZoom); + }, + [clampZoom, onPan, onZoom, panX, panY, 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..187fb3b --- /dev/null +++ b/apps/probe-viewer/src/components/ProbeOverview.tsx @@ -0,0 +1,253 @@ +import { useEffect, useRef, useMemo } from "react"; +import type { ProbeInterfaceFile } from "../types/probe"; + +interface ProbeOverviewProps { + probeData: ProbeInterfaceFile; + zoom: number; + panX: number; + panY: number; + /** Main canvas dimensions */ + mainWidth: number; + mainHeight: number; + onPan?: (x: number, y: 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 = 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, + panX, + panY, + mainWidth, + mainHeight, + onPan, +}: ProbeOverviewProps) { + const canvasRef = useRef(null); + const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]); + const probe = useMemo(() => probeData.probes?.[0], [probeData]); + + // 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; + + // Center of visible area in probe coordinates + // In main canvas: normX = (x - centerX) * scale + width/2 + panX + // Solving for center of viewport: when normX = width/2, x = centerX - panX/scale + const viewCenterX = geometry.centerX - panX / mainScale; + const viewCenterY = geometry.centerY + panY / mainScale; // Y is inverted + + // Convert to minimap coordinates + const viewRectWidth = visibleWidthUm * minimapScale; + const viewRectHeight = visibleHeightUm * minimapScale; + const viewRectX = (viewCenterX - geometry.centerX) * minimapScale + offsetX - viewRectWidth / 2; + const viewRectY = -(viewCenterY - 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, panX, panY, mainWidth, mainHeight]); + + // Handle click to pan + const handleClick = (event: React.MouseEvent) => { + if (!geometry || !onPan || 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; + + // Convert click to minimap coordinates + const padding = 8; + const availW = MINIMAP_WIDTH - padding * 2; + const availH = MINIMAP_HEIGHT - padding * 2; + const minimapScale = Math.min(availW / geometry.width, availH / geometry.height); + + const offsetX = MINIMAP_WIDTH / 2; + const offsetY = MINIMAP_HEIGHT / 2; + + // Convert click position to probe coordinates + const probeX = (clickX - offsetX) / minimapScale + geometry.centerX; + const probeY = geometry.centerY - (clickY - offsetY) / minimapScale; // Y inverted + + // Calculate pan to center on this point + 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; + + const newPanX = -(probeX - geometry.centerX) * mainScale; + const newPanY = (probeY - geometry.centerY) * mainScale; + + onPan(newPanX, newPanY); + }; + + 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..3cf5f97 --- /dev/null +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -0,0 +1,332 @@ +import { useCallback, useEffect, useMemo, useRef } 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 { JsonTree } from "./JsonTree"; +import { ProbeCanvas } from "./ProbeCanvas"; +import { ProbeOverview } from "./ProbeOverview"; + +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 setPan = useAppStore((state) => state.setPan); + 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; + + // 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, panX: view.panX, panY: view.panY }, + { width: canvasSize.width, height: canvasSize.height }, + `${entry.id}.png` + ); + } + }, [probeData, entry, view.zoom, view.panX, view.panY, canvasSize.width, canvasSize.height]); + + const handleExportSvg = useCallback(() => { + if (probeData && entry) { + exportProbeAsSvg( + probeData, + { zoom: view.zoom, panX: view.panX, panY: view.panY }, + { width: canvasSize.width, height: canvasSize.height }, + `${entry.id}.svg` + ); + } + }, [probeData, entry, view.zoom, view.panX, view.panY, canvasSize.width, canvasSize.height]); + + const lastResetProbeId = useRef(undefined); + + useEffect(() => { + if (selectedProbeId && lastResetProbeId.current !== selectedProbeId) { + 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; + + 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 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); + + // Calculate pan to show the bottom of the probe + // Canvas projection: screenY = -(probeY - centerY) * scale + height/2 + panY + // minY (probe base) maps to screen bottom, maxY (probe tip) maps to screen top + // To show the base, we need to shift the view down (negative panY) + 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; + + // At panY=0, probe center is at screen center + // screenY of minY = -(minY - centerY) * scale + height/2 = (centerY - minY) * scale + height/2 + // We want minY to appear near bottom of viewport (with margin) + // Target screenY for minY = height - margin + // So: (centerY - minY) * scale + height/2 + panY = height - margin + // panY = height - margin - height/2 - (centerY - minY) * scale + // panY = height/2 - margin - (height/2) * scale (since centerY - minY = height/2) + const probeHalfHeightScreen = (height / 2) * mainScale; + const initialPanY = canvasSize.height / 2 - mainPadding - probeHalfHeightScreen; + + setPan(0, initialPanY); + } + + lastSmartZoomProbeId.current = selectedProbeId; + }, [probeData, selectedProbeId, setZoom, setPan, canvasSize.width, canvasSize.height]); + + if (manifestStatus === "loading") { + return ( +
+

Loading manifest…

+
+ ); + } + + if (manifestStatus === "error") { + return ( +
+

{statusMessage ?? "Unable to load catalog."}

+
+ ); + } + + if (!entry) { + return ( +
+

Select a probe to see its details.

+
+ ); + } + + return ( +
+
+
+

{entry.displayName}

+

+ {entry.manufacturer} · {entry.contactCount} contacts ·{" "} + {entry.shankCount} shanks +

+
+
+ + + + Download JSON + +
+
+ +
+
+ + + +
+
+ + + +
+
+ +
+ {status === "error" && ( +
+

{statusMessage ?? "Failed to load probe data."}

+
+ )} + {status !== "error" && probeData && ( + <> + setPan(nextX, nextY)} + onZoom={(value) => setZoom(value)} + /> + {view.showOverview && ( + setPan(nextX, nextY)} + /> + )} + + )} + {status === "loading" && ( +
+

Loading probe geometry…

+
+ )} +
+ + + +
+
+

Probe JSON

+ {status === "success" && probeData && ( + + {probeData.specification} · v{probeData.version} + + )} +
+ {status === "loading" &&

Fetching probe data…

} + {status === "error" && ( +

{statusMessage}

+ )} + {status === "success" && probeData && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/probe-viewer/src/components/Sidebar.tsx b/apps/probe-viewer/src/components/Sidebar.tsx new file mode 100644 index 0000000..9ed05da --- /dev/null +++ b/apps/probe-viewer/src/components/Sidebar.tsx @@ -0,0 +1,125 @@ +import { useEffect, useMemo } from "react"; + +import { useAppStore } from "../state/useAppStore"; + +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 ( +
+
+

Probe Catalog

+

+ Browse available probe layouts and inspect their geometry. +

+
+ +
+ + +
+ +
+ + 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) => ( + + ))} +
+
+ ); +} 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..2bd1cc5 --- /dev/null +++ b/apps/probe-viewer/src/index.css @@ -0,0 +1,33 @@ +: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; + min-height: 100vh; + background-color: inherit; + color: inherit; +} + +a { + color: inherit; +} + +#root { + min-height: 100vh; +} 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..eaadc88 --- /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; + panX: number; + panY: number; + 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; + setPan: (panX: number, panY: number) => 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, + panX: 0, + panY: 0, + 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), + }, + })), + + setPan: (panX, panY) => + set((state) => ({ + view: { + ...state.view, + panX, + panY, + }, + })), + + 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..136e03f --- /dev/null +++ b/apps/probe-viewer/src/types/probe.ts @@ -0,0 +1,46 @@ +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[]; + 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..f9363c5 --- /dev/null +++ b/apps/probe-viewer/src/utils/exportUtils.ts @@ -0,0 +1,353 @@ +import type { ProbeInterfaceFile, ContactShapeParams } from "../types/probe"; + +interface ExportViewState { + zoom: number; + panX: number; + panY: number; +} + +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 scale bar or contact IDs. + */ +export function exportProbeAsPng( + probeData: ProbeInterfaceFile, + viewState: ExportViewState, + canvasSize: CanvasSize, + filename: string +): 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 scale bar, no IDs) + renderProbeToContext(ctx, probeData, viewState, canvasSize); + + // 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 scale bar or contact IDs. + */ +export function exportProbeAsSvg( + probeData: ProbeInterfaceFile, + viewState: ExportViewState, + canvasSize: CanvasSize, + filename: string +): void { + const svgString = generateProbeSvgString(probeData, viewState, canvasSize); + 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 scale bar or contact IDs. + */ +function renderProbeToContext( + ctx: CanvasRenderingContext2D, + probeData: ProbeInterfaceFile, + viewState: ExportViewState, + canvasSize: CanvasSize +): void { + const geometry = computeGeometrySummary(probeData); + const probe = probeData.probes?.[0]; + if (!geometry || !probe) return; + + const { zoom, panX, panY } = viewState; + const { width: widthPx, height: heightPx } = canvasSize; + + 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; + + 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(); + }); +} + +/** + * Generate SVG string for probe visualization. + * Transparent background, no scale bar, no contact IDs. + */ +function generateProbeSvgString( + probeData: ProbeInterfaceFile, + viewState: ExportViewState, + canvasSize: CanvasSize +): string { + const geometry = computeGeometrySummary(probeData); + const probe = probeData.probes?.[0]; + + if (!geometry || !probe) { + return ``; + } + + const { zoom, panX, panY } = viewState; + const { width: widthPx, height: heightPx } = canvasSize; + + 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; + + 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 ``; + } + } + }; + + // First pass: shadows + contactPositions.forEach((position, index) => { + const [x, y] = projectPoint(position); + 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); + const shape = contactShapes[index] ?? ""; + const params = contactShapeParams[index] ?? {}; + elements.push(generateContactSvg(x, y, shape, params, false)); + }); + + 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/', +}) From a7f7a4855c16e28206561654aa5792d1e59d0b21 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 8 Jun 2026 14:53:17 -0600 Subject: [PATCH 2/6] first draft --- apps/probe-viewer/src/App.css | 139 +++------- apps/probe-viewer/src/App.tsx | 80 +++++- apps/probe-viewer/src/components/JsonTree.tsx | 126 --------- .../src/components/ProbeCanvas.tsx | 242 ++++++++++++++---- .../src/components/ProbeOverview.tsx | 52 ++-- .../src/components/ProbeViewer.tsx | 209 ++++++++++----- apps/probe-viewer/src/components/Sidebar.tsx | 10 +- apps/probe-viewer/src/index.css | 6 +- apps/probe-viewer/src/state/useAppStore.ts | 16 +- apps/probe-viewer/src/utils/exportUtils.ts | 142 ++++++++-- 10 files changed, 607 insertions(+), 415 deletions(-) delete mode 100644 apps/probe-viewer/src/components/JsonTree.tsx diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index b692fdb..3b98a2a 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -1,6 +1,7 @@ .app-shell { display: flex; - min-height: 100vh; + height: 100%; + overflow: hidden; background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); } @@ -13,6 +14,8 @@ .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; @@ -82,6 +85,7 @@ .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; @@ -141,7 +145,7 @@ flex: 1; display: flex; flex-direction: column; - gap: 1.5rem; + gap: 1rem; max-width: 960px; width: 100%; background: rgba(255, 255, 255, 0.95); @@ -162,6 +166,29 @@ 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; @@ -182,6 +209,10 @@ } .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; @@ -248,6 +279,9 @@ } .viewer-download { + display: inline-flex; + align-items: center; + gap: 0.35rem; font-size: 0.9rem; padding: 0.5rem 0.75rem; border-radius: 0.75rem; @@ -306,107 +340,6 @@ text-decoration: underline; } -.viewer-json-panel { - background: rgba(248, 250, 252, 0.95); - border-radius: 1rem; - padding: 1.25rem; - border: 1px solid rgba(148, 163, 184, 0.3); - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.viewer-json-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.75rem; -} - -.viewer-json-header h3 { - margin: 0; - font-size: 1rem; -} - -.viewer-json-meta { - font-size: 0.85rem; - color: #475569; -} - -.viewer-json { - margin: 0; - padding: 1rem; - background: #0f172a; - color: #e2e8f0; - border-radius: 0.75rem; - font-size: 0.85rem; - overflow: auto; - max-height: 360px; - font-family: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; -} - -.json-tree-node > summary { - cursor: pointer; - list-style: none; - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.5rem; - color: #cbd5f5; -} - -.json-tree-node > summary:hover { - color: #94a3ff; -} - -.json-tree-node > summary::marker, -.json-tree-node > summary::-webkit-details-marker { - display: none; -} - -.json-tree-node > summary::before { - content: "▸"; - display: inline-block; - margin-right: 0.35rem; - transition: transform 0.15s ease; -} - -.json-tree-node[open] > summary::before { - transform: rotate(90deg); -} - -.json-tree-key { - font-weight: 600; - color: #f8fafc; -} - -.json-tree-meta { - font-size: 0.75rem; - color: rgba(226, 232, 240, 0.7); -} - -.json-tree-children { - margin-left: 1rem; - border-left: 1px solid rgba(148, 163, 184, 0.3); - padding-left: 0.75rem; - margin-top: 0.4rem; - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.json-tree-item { - margin-left: 1.35rem; - color: #e2e8f0; - display: flex; - align-items: baseline; - gap: 0.4rem; -} - -.json-tree-value--primitive { - color: #facc15; -} - .viewer-placeholder { flex: 1; display: flex; diff --git a/apps/probe-viewer/src/App.tsx b/apps/probe-viewer/src/App.tsx index 1f74b12..3368bbd 100644 --- a/apps/probe-viewer/src/App.tsx +++ b/apps/probe-viewer/src/App.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useLocation, useNavigate, useParams, + useSearchParams, } from "react-router-dom"; import { ProbeViewer } from "./components/ProbeViewer"; @@ -10,10 +11,18 @@ 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); @@ -21,10 +30,72 @@ function App() { 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)); @@ -43,8 +114,11 @@ function App() { ? manifestById.get(selectedProbeId) : undefined; + const getDefaultProbe = () => + manifestById.get(DEFAULT_PROBE_ID) ?? manifest[0]; + if (selectedProbeId && !currentSelected) { - const fallback = routeEntry ?? manifest[0]; + const fallback = routeEntry ?? getDefaultProbe(); if (fallback && fallback.id !== selectedProbeId) { selectProbe(fallback.id); } @@ -55,7 +129,7 @@ function App() { if (routeEntry) { selectProbe(routeEntry.id); } else { - const fallback = manifest[0]; + const fallback = getDefaultProbe(); if (fallback) { selectProbe(fallback.id); } diff --git a/apps/probe-viewer/src/components/JsonTree.tsx b/apps/probe-viewer/src/components/JsonTree.tsx deleted file mode 100644 index 8b97d49..0000000 --- a/apps/probe-viewer/src/components/JsonTree.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useEffect, useState } from "react"; - -interface JsonTreeProps { - data: unknown; - name?: string; - path?: string; - depth?: number; - defaultExpanded?: boolean; -} - -function formatPrimitive(value: unknown): string { - if (typeof value === "string") { - return `"${value}"`; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (value === null) { - return "null"; - } - if (typeof value === "undefined") { - return "undefined"; - } - return JSON.stringify(value); -} - -export function JsonTree({ - data, - name, - path = "root", - depth = 0, - defaultExpanded = depth === 0, -}: JsonTreeProps) { - const isArray = Array.isArray(data); - const isPlainObject = - data !== null && - typeof data === "object" && - !isArray; - - if (!isArray && !isPlainObject) { - return ( -
- {name !== undefined && {name}: } - - {formatPrimitive(data)} - -
- ); - } - - return ( - - ); -} - -interface JsonTreeBranchProps { - data: unknown; - name?: string; - path: string; - depth: number; - defaultExpanded: boolean; -} - -function JsonTreeBranch({ - data, - name, - path, - depth, - defaultExpanded, -}: JsonTreeBranchProps) { - const isArray = Array.isArray(data); - const entries = isArray - ? (data as unknown[]).map((value, index) => ({ - key: String(index), - value, - label: `[${index}]`, - })) - : Object.entries(data as Record).map(([key, value]) => ({ - key, - value, - label: key, - })); - - const summaryMeta = isArray - ? `[${entries.length}]` - : `{${entries.length}}`; - - const nodeName = name ?? (isArray ? "Array" : "Object"); - - const [isOpen, setIsOpen] = useState(defaultExpanded); - - useEffect(() => { - setIsOpen(defaultExpanded); - }, [defaultExpanded, path]); - - return ( -
setIsOpen(event.currentTarget.open)} - > - - {nodeName} - {summaryMeta} - -
- {entries.map((entry) => ( - - ))} -
-
- ); -} diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx index b33439a..06ec976 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -10,7 +10,6 @@ import { import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, - WheelEvent as ReactWheelEvent, } from "react"; import { useResizeObserver } from "../hooks/useResizeObserver"; @@ -21,11 +20,11 @@ interface ProbeCanvasProps { entry: ManifestEntry; probeData: ProbeInterfaceFile; zoom: number; - panX: number; - panY: number; + viewCenterX: number | null; // probe coordinates (µm), null = geometry center + viewCenterY: number | null; showContactIds: boolean; showScaleBar: boolean; - onPan: (x: number, y: number) => void; + onViewCenterChange: (x: number | null, y: number | null) => void; onZoom: (zoom: number) => void; } @@ -81,11 +80,11 @@ export const ProbeCanvas = forwardRef( entry, probeData, zoom, - panX, - panY, + viewCenterX, + viewCenterY, showContactIds, showScaleBar, - onPan, + onViewCenterChange, onZoom, }, ref @@ -96,11 +95,23 @@ export const ProbeCanvas = forwardRef( useImperativeHandle(ref, () => canvasRef.current!, []); const { ref: containerRef, size } = useResizeObserver(); const [isDragging, setIsDragging] = useState(false); - const dragOriginRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null); + 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; @@ -115,10 +126,20 @@ export const ProbeCanvas = forwardRef( const devicePixelRatio = window.devicePixelRatio || 1; const widthPx = size.width; const heightPx = size.height; - canvas.width = widthPx * devicePixelRatio; - canvas.height = heightPx * devicePixelRatio; - canvas.style.width = `${widthPx}px`; - canvas.style.height = `${heightPx}px`; + // 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); @@ -132,6 +153,10 @@ export const ProbeCanvas = forwardRef( ); 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; @@ -314,50 +339,112 @@ export const ProbeCanvas = forwardRef( if (showScaleBar) { renderScaleBar(); } - }, [entry.id, geometry, panX, panY, probe, probeData, showContactIds, showScaleBar, size.height, size.width, zoom]); + }, [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)), [], ); - const handleWheel = useCallback( - (event: ReactWheelEvent) => { + // 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; - // Get mouse 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 centerX = rect.width / 2; - const centerY = rect.height / 2; + const offsetFromCenterX = event.clientX - rect.left - rect.width / 2; + const offsetFromCenterY = event.clientY - rect.top - rect.height / 2; - // Mouse offset from center (in screen pixels) - const offsetFromCenterX = mouseX - centerX; - const offsetFromCenterY = mouseY - centerY; + const scale = getScale(); + const panX = (geometry.centerX - effectiveViewCenterX) * scale; + const panY = (effectiveViewCenterY - geometry.centerY) * scale; - // Calculate new zoom - const zoomFactor = Math.exp(-event.deltaY * 0.002); + const zoomFactor = Math.exp(-delta * 0.002); const nextZoom = clampZoom(zoom * zoomFactor); const actualZoomFactor = nextZoom / zoom; - // Adjust pan so the point under the mouse stays fixed - // The point under mouse in current view: (panX + offsetFromCenterX, panY + offsetFromCenterY) - // After zoom, we want the same world point under mouse, so: - // newPanX + offsetFromCenterX = (panX + offsetFromCenterX) * actualZoomFactor - // newPanX = panX * actualZoomFactor + offsetFromCenterX * (actualZoomFactor - 1) - const newPanX = panX * actualZoomFactor + offsetFromCenterX * (actualZoomFactor - 1); - const newPanY = panY * actualZoomFactor + offsetFromCenterY * (actualZoomFactor - 1); + // 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; - onPan(newPanX, newPanY); + onViewCenterChange(newViewCenterX, newViewCenterY); onZoom(nextZoom); - }, - [clampZoom, onPan, onZoom, panX, panY, zoom], - ); + }; + + canvas.addEventListener("wheel", handleWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", handleWheel); + }, [geometry, probe]); const handlePointerDown = useCallback((event: ReactPointerEvent) => { event.preventDefault(); @@ -365,11 +452,11 @@ export const ProbeCanvas = forwardRef( dragOriginRef.current = { x: event.clientX, y: event.clientY, - panX, - panY, + viewCenterX: effectiveViewCenterX, + viewCenterY: effectiveViewCenterY, }; (event.target as HTMLCanvasElement).setPointerCapture(event.pointerId); - }, [panX, panY]); + }, [effectiveViewCenterX, effectiveViewCenterY]); const handlePointerMove = useCallback((event: ReactPointerEvent) => { if (!isDragging || !dragOriginRef.current) { @@ -378,21 +465,54 @@ export const ProbeCanvas = forwardRef( event.preventDefault(); const deltaX = event.clientX - dragOriginRef.current.x; const deltaY = event.clientY - dragOriginRef.current.y; - onPan(dragOriginRef.current.panX + deltaX, dragOriginRef.current.panY + deltaY); - }, [isDragging, onPan]); + + // 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]); + }, [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; @@ -402,26 +522,37 @@ export const ProbeCanvas = forwardRef( const mouseY = event.clientY - rect.top; // Canvas center - const centerX = rect.width / 2; - const centerY = rect.height / 2; + const canvasCenterX = rect.width / 2; + const canvasCenterY = rect.height / 2; // Mouse offset from center - const offsetFromCenterX = mouseX - centerX; - const offsetFromCenterY = mouseY - centerY; + 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 - const newPanX = panX * actualZoomFactor + offsetFromCenterX * (actualZoomFactor - 1); - const newPanY = panY * actualZoomFactor + offsetFromCenterY * (actualZoomFactor - 1); + // 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; - onPan(newPanX, newPanY); + onViewCenterChange(newViewCenterX, newViewCenterY); onZoom(nextZoom); }, - [clampZoom, onPan, onZoom, panX, panY, zoom], + [clampZoom, effectiveViewCenterX, effectiveViewCenterY, geometry, getScale, onViewCenterChange, onZoom, zoom], ); return ( @@ -431,7 +562,6 @@ export const ProbeCanvas = forwardRef( ref={canvasRef} role="img" aria-label={`${entry.displayName} planar layout`} - onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} diff --git a/apps/probe-viewer/src/components/ProbeOverview.tsx b/apps/probe-viewer/src/components/ProbeOverview.tsx index 187fb3b..5fb6edc 100644 --- a/apps/probe-viewer/src/components/ProbeOverview.tsx +++ b/apps/probe-viewer/src/components/ProbeOverview.tsx @@ -4,12 +4,12 @@ import type { ProbeInterfaceFile } from "../types/probe"; interface ProbeOverviewProps { probeData: ProbeInterfaceFile; zoom: number; - panX: number; - panY: number; + viewCenterX: number | null; // probe coordinates (µm), null = geometry center + viewCenterY: number | null; /** Main canvas dimensions */ mainWidth: number; mainHeight: number; - onPan?: (x: number, y: number) => void; + onViewCenterChange?: (x: number | null, y: number | null) => void; } interface GeometrySummary { @@ -54,16 +54,20 @@ function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary export function ProbeOverview({ probeData, zoom, - panX, - panY, + viewCenterX, + viewCenterY, mainWidth, mainHeight, - onPan, + 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; @@ -139,17 +143,11 @@ export function ProbeOverview({ const visibleWidthUm = mainWidth / mainScale; const visibleHeightUm = mainHeight / mainScale; - // Center of visible area in probe coordinates - // In main canvas: normX = (x - centerX) * scale + width/2 + panX - // Solving for center of viewport: when normX = width/2, x = centerX - panX/scale - const viewCenterX = geometry.centerX - panX / mainScale; - const viewCenterY = geometry.centerY + panY / mainScale; // Y is inverted - - // Convert to minimap coordinates + // Convert to minimap coordinates using the effective view center const viewRectWidth = visibleWidthUm * minimapScale; const viewRectHeight = visibleHeightUm * minimapScale; - const viewRectX = (viewCenterX - geometry.centerX) * minimapScale + offsetX - viewRectWidth / 2; - const viewRectY = -(viewCenterY - geometry.centerY) * minimapScale + offsetY - viewRectHeight / 2; + 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 @@ -201,11 +199,11 @@ export function ProbeOverview({ ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, MINIMAP_WIDTH - 1, MINIMAP_HEIGHT - 1); - }, [geometry, probe, zoom, panX, panY, mainWidth, mainHeight]); + }, [geometry, probe, zoom, effectiveViewCenterX, effectiveViewCenterY, mainWidth, mainHeight]); // Handle click to pan const handleClick = (event: React.MouseEvent) => { - if (!geometry || !onPan || mainWidth === 0 || mainHeight === 0) return; + if (!geometry || !onViewCenterChange || mainWidth === 0 || mainHeight === 0) return; const canvas = canvasRef.current; if (!canvas) return; @@ -214,30 +212,22 @@ export function ProbeOverview({ const clickX = event.clientX - rect.left; const clickY = event.clientY - rect.top; - // Convert click to minimap coordinates + // Account for title offset + const titleHeight = 16; const padding = 8; const availW = MINIMAP_WIDTH - padding * 2; - const availH = MINIMAP_HEIGHT - 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 / 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 - // Calculate pan to center on this point - 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; - - const newPanX = -(probeX - geometry.centerX) * mainScale; - const newPanY = (probeY - geometry.centerY) * mainScale; - - onPan(newPanX, newPanY); + // Set view center to the clicked point + onViewCenterChange(probeX, probeY); }; if (!geometry || !probe) return null; diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index 3cf5f97..c9177ca 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -1,12 +1,52 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +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 { JsonTree } from "./JsonTree"; 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); @@ -17,7 +57,7 @@ export function ProbeViewer() { const probeStatus = useAppStore((state) => state.probeStatus); const view = useAppStore((state) => state.view); const setZoom = useAppStore((state) => state.setZoom); - const setPan = useAppStore((state) => state.setPan); + const setViewCenter = useAppStore((state) => state.setViewCenter); const resetView = useAppStore((state) => state.resetView); const toggleContactIds = useAppStore((state) => state.toggleContactIds); const toggleScaleBar = useAppStore((state) => state.toggleScaleBar); @@ -51,29 +91,46 @@ export function ProbeViewer() { if (probeData && entry) { exportProbeAsPng( probeData, - { zoom: view.zoom, panX: view.panX, panY: view.panY }, + { zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY }, { width: canvasSize.width, height: canvasSize.height }, - `${entry.id}.png` + `${entry.id}.png`, + view.showScaleBar ); } - }, [probeData, entry, view.zoom, view.panX, view.panY, canvasSize.width, canvasSize.height]); + }, [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, panX: view.panX, panY: view.panY }, + { zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY }, { width: canvasSize.width, height: canvasSize.height }, - `${entry.id}.svg` + `${entry.id}.svg`, + view.showScaleBar ); } - }, [probeData, entry, view.zoom, view.panX, view.panY, canvasSize.width, canvasSize.height]); + }, [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) { - resetView(); + // 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) { @@ -91,6 +148,14 @@ export function ProbeViewer() { // 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; @@ -112,6 +177,7 @@ export function ProbeViewer() { 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; @@ -122,31 +188,25 @@ export function ProbeViewer() { const initialZoom = aspectRatio * TARGET_WIDTH_FRACTION; setZoom(initialZoom); - // Calculate pan to show the bottom of the probe - // Canvas projection: screenY = -(probeY - centerY) * scale + height/2 + panY - // minY (probe base) maps to screen bottom, maxY (probe tip) maps to screen top - // To show the base, we need to shift the view down (negative panY) + // 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; - // At panY=0, probe center is at screen center - // screenY of minY = -(minY - centerY) * scale + height/2 = (centerY - minY) * scale + height/2 - // We want minY to appear near bottom of viewport (with margin) - // Target screenY for minY = height - margin - // So: (centerY - minY) * scale + height/2 + panY = height - margin - // panY = height - margin - height/2 - (centerY - minY) * scale - // panY = height/2 - margin - (height/2) * scale (since centerY - minY = height/2) - const probeHalfHeightScreen = (height / 2) * mainScale; - const initialPanY = canvasSize.height / 2 - mainPadding - probeHalfHeightScreen; - - setPan(0, initialPanY); + // 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, setPan, canvasSize.width, canvasSize.height]); + }, [probeData, selectedProbeId, setZoom, setViewCenter, canvasSize.width, canvasSize.height]); if (manifestStatus === "loading") { return ( @@ -179,24 +239,36 @@ export function ProbeViewer() {

{entry.displayName}

{entry.manufacturer} · {entry.contactCount} contacts ·{" "} - {entry.shankCount} shanks + {entry.shankCount} shanks ·{" "} + + {JsonIcon} + JSON +

- - - - Download JSON - + Export SVG +
@@ -205,17 +277,37 @@ export function ProbeViewer() { + -
@@ -258,22 +350,22 @@ export function ProbeViewer() { entry={entry} probeData={probeData} zoom={view.zoom} - panX={view.panX} - panY={view.panY} + viewCenterX={view.viewCenterX} + viewCenterY={view.viewCenterY} showContactIds={view.showContactIds} showScaleBar={view.showScaleBar} - onPan={(nextX, nextY) => setPan(nextX, nextY)} + onViewCenterChange={(x, y) => setViewCenter(x, y)} onZoom={(value) => setZoom(value)} /> {view.showOverview && ( setPan(nextX, nextY)} + onViewCenterChange={(x, y) => setViewCenter(x, y)} /> )} @@ -304,29 +396,6 @@ export function ProbeViewer() {
-
-
-

Probe JSON

- {status === "success" && probeData && ( - - {probeData.specification} · v{probeData.version} - - )} -
- {status === "loading" &&

Fetching probe data…

} - {status === "error" && ( -

{statusMessage}

- )} - {status === "success" && probeData && ( -
- -
- )} -
); } diff --git a/apps/probe-viewer/src/components/Sidebar.tsx b/apps/probe-viewer/src/components/Sidebar.tsx index 9ed05da..5a3477a 100644 --- a/apps/probe-viewer/src/components/Sidebar.tsx +++ b/apps/probe-viewer/src/components/Sidebar.tsx @@ -2,6 +2,14 @@ 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); @@ -72,7 +80,7 @@ export function Sidebar() { > {manufacturers.map((manufacturer) => ( ))} diff --git a/apps/probe-viewer/src/index.css b/apps/probe-viewer/src/index.css index 2bd1cc5..4d29991 100644 --- a/apps/probe-viewer/src/index.css +++ b/apps/probe-viewer/src/index.css @@ -19,7 +19,9 @@ body { margin: 0; min-width: 320px; - min-height: 100vh; + 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; } @@ -29,5 +31,5 @@ a { } #root { - min-height: 100vh; + height: 100%; } diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index eaadc88..0a37206 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -13,8 +13,8 @@ interface ProbeLoadState { interface ViewState { zoom: number; - panX: number; - panY: number; + viewCenterX: number | null; // null = centered on geometry center + viewCenterY: number | null; // in probe coordinates (micrometers) showContactIds: boolean; showScaleBar: boolean; showOverview: boolean; @@ -37,7 +37,7 @@ interface AppState { selectProbe: (probeId?: string) => void; ensureProbeLoaded: (probeId: string) => Promise; setZoom: (zoom: number) => void; - setPan: (panX: number, panY: number) => void; + setViewCenter: (x: number | null, y: number | null) => void; resetView: () => void; toggleContactIds: (value?: boolean) => void; toggleScaleBar: (value?: boolean) => void; @@ -49,8 +49,8 @@ export const VIEW_ZOOM_MAX = 100; // High max for long probes like Neuropixels const INITIAL_VIEW_STATE: ViewState = { zoom: 1, - panX: 0, - panY: 0, + viewCenterX: null, + viewCenterY: null, showContactIds: false, showScaleBar: true, showOverview: true, @@ -175,12 +175,12 @@ export const useAppStore = create((set, get) => ({ }, })), - setPan: (panX, panY) => + setViewCenter: (x, y) => set((state) => ({ view: { ...state.view, - panX, - panY, + viewCenterX: x, + viewCenterY: y, }, })), diff --git a/apps/probe-viewer/src/utils/exportUtils.ts b/apps/probe-viewer/src/utils/exportUtils.ts index f9363c5..4c10c45 100644 --- a/apps/probe-viewer/src/utils/exportUtils.ts +++ b/apps/probe-viewer/src/utils/exportUtils.ts @@ -2,8 +2,8 @@ import type { ProbeInterfaceFile, ContactShapeParams } from "../types/probe"; interface ExportViewState { zoom: number; - panX: number; - panY: number; + viewCenterX: number | null; + viewCenterY: number | null; } interface CanvasSize { @@ -59,13 +59,14 @@ function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary /** * Export probe visualization as PNG with white background. - * Re-renders the probe without scale bar or contact IDs. + * Re-renders the probe without contact IDs. Scale bar included if enabled. */ export function exportProbeAsPng( probeData: ProbeInterfaceFile, viewState: ExportViewState, canvasSize: CanvasSize, - filename: string + filename: string, + showScaleBar: boolean ): void { const canvas = document.createElement("canvas"); const dpr = window.devicePixelRatio || 1; @@ -81,8 +82,8 @@ export function exportProbeAsPng( ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); - // Render probe (no scale bar, no IDs) - renderProbeToContext(ctx, probeData, viewState, canvasSize); + // Render probe (no contact IDs, scale bar if enabled) + renderProbeToContext(ctx, probeData, viewState, canvasSize, showScaleBar); // Download const link = document.createElement("a"); @@ -93,15 +94,16 @@ export function exportProbeAsPng( /** * Export probe visualization as SVG with transparent background. - * Re-renders the probe without scale bar or contact IDs. + * Re-renders the probe without contact IDs. Scale bar included if enabled. */ export function exportProbeAsSvg( probeData: ProbeInterfaceFile, viewState: ExportViewState, canvasSize: CanvasSize, - filename: string + filename: string, + showScaleBar: boolean ): void { - const svgString = generateProbeSvgString(probeData, viewState, canvasSize); + const svgString = generateProbeSvgString(probeData, viewState, canvasSize, showScaleBar); const blob = new Blob([svgString], { type: "image/svg+xml" }); const link = document.createElement("a"); link.download = filename; @@ -112,21 +114,26 @@ export function exportProbeAsSvg( /** * Render probe to a 2D canvas context (used for PNG export). - * Mirrors the ProbeCanvas rendering logic but without scale bar or contact IDs. + * Mirrors the ProbeCanvas rendering logic but without contact IDs. */ function renderProbeToContext( ctx: CanvasRenderingContext2D, probeData: ProbeInterfaceFile, viewState: ExportViewState, - canvasSize: CanvasSize + canvasSize: CanvasSize, + showScaleBar: boolean ): void { const geometry = computeGeometrySummary(probeData); const probe = probeData.probes?.[0]; if (!geometry || !probe) return; - const { zoom, panX, panY } = viewState; + 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); @@ -136,6 +143,10 @@ function renderProbeToContext( ); 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; @@ -236,16 +247,70 @@ function renderProbeToContext( 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 scale bar, no contact IDs. + * Transparent background, no contact IDs. Scale bar included if enabled. */ function generateProbeSvgString( probeData: ProbeInterfaceFile, viewState: ExportViewState, - canvasSize: CanvasSize + canvasSize: CanvasSize, + showScaleBar: boolean ): string { const geometry = computeGeometrySummary(probeData); const probe = probeData.probes?.[0]; @@ -254,9 +319,13 @@ function generateProbeSvgString( return ``; } - const { zoom, panX, panY } = viewState; + 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); @@ -266,6 +335,10 @@ function generateProbeSvgString( ); 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; @@ -347,6 +420,45 @@ function generateProbeSvgString( 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")} `; From 6da9f968804ab416731d10f766eb54213de08501 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 8 Jun 2026 15:04:32 -0600 Subject: [PATCH 3/6] fix test --- tests.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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) From f3cc20ac3a77e8a969037c5ac64cd86424ce844b Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 8 Jun 2026 15:23:43 -0600 Subject: [PATCH 4/6] expose some status --- apps/probe-viewer/src/components/ProbeCanvas.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx index 06ec976..7f0b0ed 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -562,6 +562,12 @@ export const ProbeCanvas = forwardRef( ref={canvasRef} role="img" aria-label={`${entry.displayName} planar layout`} + // Reflect the current view state onto the DOM so end-to-end tests can + // read it directly (instead of parsing the hash). These mirror the URL + // params: cx/cy are omitted at the default view, exactly like the URL. + data-zoom={zoom} + data-view-cx={viewCenterX ?? undefined} + data-view-cy={viewCenterY ?? undefined} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} From 3deb5a4c7b8f625fcf2a959cc0a5ee7804dd25c5 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 8 Jun 2026 15:31:33 -0600 Subject: [PATCH 5/6] fix svg export --- apps/probe-viewer/src/utils/exportUtils.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/probe-viewer/src/utils/exportUtils.ts b/apps/probe-viewer/src/utils/exportUtils.ts index 4c10c45..d071d4f 100644 --- a/apps/probe-viewer/src/utils/exportUtils.ts +++ b/apps/probe-viewer/src/utils/exportUtils.ts @@ -305,6 +305,8 @@ function renderProbeToContext( /** * 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, @@ -402,9 +404,23 @@ function generateProbeSvgString( } }; + // 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( @@ -415,6 +431,7 @@ function generateProbeSvgString( // 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)); From 742cf3021a4e54efa4a69e1f682d80d1822b1d06 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 8 Jun 2026 15:37:48 -0600 Subject: [PATCH 6/6] contact ids fix --- .../src/components/ProbeCanvas.tsx | 6 ++++-- .../src/components/ProbeViewer.tsx | 21 ++++++++++++------- apps/probe-viewer/src/types/probe.ts | 1 + 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx index 7f0b0ed..d9327ba 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -261,14 +261,16 @@ export const ProbeCanvas = forwardRef( ctx.stroke(); }); - if (showContactIds) { + 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); - ctx.fillText(String(index), x, y + 4); + // Show the probe's actual contact id, not the array index. + ctx.fillText(String(contactIds[index] ?? index), x, y + 4); }); } diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index c9177ca..d081039 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -83,6 +83,9 @@ export function ProbeViewer() { 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(); @@ -311,14 +314,16 @@ export function ProbeViewer() {
- + {hasContactIds && ( + + )}