From c6f2cad36d1e46ba888595c706f54004e1878bbb Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 02:03:44 +0100 Subject: [PATCH 01/47] feat(graph-viewer): add server URL config to TokenPrompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add server URL input field with default Railway URL - Use setServerConfig to persist both URL and token - Test connection before saving credentials - Improve error display with styled alert box 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/TokenPrompt.tsx | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 packages/graph-viewer/src/components/TokenPrompt.tsx diff --git a/packages/graph-viewer/src/components/TokenPrompt.tsx b/packages/graph-viewer/src/components/TokenPrompt.tsx new file mode 100644 index 0000000..82fa6ca --- /dev/null +++ b/packages/graph-viewer/src/components/TokenPrompt.tsx @@ -0,0 +1,141 @@ +import { useState, FormEvent } from 'react' +import { KeyRound, ArrowRight, AlertCircle, Server } from 'lucide-react' +import { checkHealth, setServerConfig } from '../api/client' + +interface TokenPromptProps { + onSubmit: (token: string) => void +} + +export function TokenPrompt({ onSubmit }: TokenPromptProps) { + const [serverUrl, setServerUrl] = useState('https://automem.up.railway.app') + const [token, setToken] = useState('') + const [isValidating, setIsValidating] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (!token.trim() || !serverUrl.trim()) return + + setIsValidating(true) + setError(null) + + try { + // Test connection to the server + await checkHealth(serverUrl) + + // Store config and notify parent + setServerConfig(serverUrl, token) + onSubmit(token) + } catch (err) { + setError((err as Error).message || 'Connection failed') + } finally { + setIsValidating(false) + } + } + + return ( +
+
+ {/* Logo */} +
+
+ AM +
+

+ AutoMem Graph Viewer +

+

+ Explore your AI memory graph in 3D +

+
+ + {/* Form */} +
+ {/* Server URL */} +
+ +
+
+ +
+ setServerUrl(e.target.value)} + placeholder="https://automem.up.railway.app" + className="w-full pl-10 pr-4 py-3 bg-black/30 border border-white/10 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-colors text-slate-100 placeholder-slate-500" + /> +
+

+ Your AutoMem server endpoint +

+
+ + {/* API Token */} +
+ +
+
+ +
+ setToken(e.target.value)} + placeholder="Enter your AutoMem API token" + className="w-full pl-10 pr-4 py-3 bg-black/30 border border-white/10 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-colors text-slate-100 placeholder-slate-500" + autoFocus + /> +
+
+ + {error && ( +
+ + {error} +
+ )} + + + +

+ Your credentials are stored locally and never sent to third parties. +

+
+ + {/* Help text */} +

+ Don't have an AutoMem server?{' '} + + Deploy one now + +

+
+
+ ) +} From 90c4df4a3821365ab31690008df5a2ec9fa0d8c0 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 02:08:00 +0100 Subject: [PATCH 02/47] feat(api): add CORS support for graph viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add flask-cors dependency - Enable CORS on all routes for cross-origin browser access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app.py | 2 ++ requirements.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/app.py b/app.py index 1702eee..acf7f5f 100644 --- a/app.py +++ b/app.py @@ -28,6 +28,7 @@ from falkordb import FalkorDB from flask import Blueprint, Flask, abort, jsonify, request +from flask_cors import CORS from qdrant_client import QdrantClient from qdrant_client import models as qdrant_models @@ -103,6 +104,7 @@ def __init__(self, id: str, vector: List[float], payload: Dict[str, Any]): sys.path.insert(0, str(root)) app = Flask(__name__) +CORS(app) # Enable CORS for all routes (graph viewer needs cross-origin access) # Legacy blueprint placeholders for deprecated route definitions below. # These are not registered with the app and are safe to keep until full removal. diff --git a/requirements.txt b/requirements.txt index 73637e1..79f9c1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # requirements.txt - Updated versions for 2024/2025 flask==3.0.3 +flask-cors==5.0.0 falkordb==1.0.9 qdrant-client==1.11.3 python-dotenv==1.0.1 From 80ad799b3d0398acb3334eea5208246c9bf4f5da Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 02:56:38 +0100 Subject: [PATCH 03/47] feat: add embedded Graph Viewer with multi-stage Docker build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add viewer blueprint to serve React SPA from /viewer/ - Support hash-based token auth (#token=xxx) for embedded mode - Enable CORS for cross-origin browser access - Optional via ENABLE_GRAPH_VIEWER env (default: true) Frontend (packages/graph-viewer/): - 3D WebGL graph visualization with React Three Fiber - Force-directed layout using d3-force-3d - Search, filter, and community isolation features - Inspector panel for memory details - Configurable server URL and token Infrastructure: - Multi-stage Dockerfile (Node.js build + Python runtime) - Frontend built during Docker build, not committed to repo Access at: https://your-server/viewer/#token=YOUR_API_TOKEN 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + Dockerfile | 25 +- app.py | 7 + automem/api/viewer.py | 70 + packages/graph-viewer/index.html | 23 + packages/graph-viewer/package-lock.json | 5897 +++++++++++++++++ packages/graph-viewer/package.json | 50 + packages/graph-viewer/postcss.config.js | 6 + packages/graph-viewer/src/App.tsx | 134 + packages/graph-viewer/src/api/client.ts | 157 + .../src/components/FilterPanel.tsx | 152 + .../src/components/GraphCanvas.tsx | 344 + .../graph-viewer/src/components/Inspector.tsx | 303 + .../graph-viewer/src/components/SearchBar.tsx | 54 + .../graph-viewer/src/components/StatsBar.tsx | 53 + packages/graph-viewer/src/hooks/useAuth.ts | 57 + .../graph-viewer/src/hooks/useForceLayout.ts | 136 + .../graph-viewer/src/hooks/useGraphData.ts | 28 + packages/graph-viewer/src/index.css | 63 + packages/graph-viewer/src/lib/types.ts | 126 + packages/graph-viewer/src/main.tsx | 22 + .../graph-viewer/src/types/d3-force-3d.d.ts | 120 + packages/graph-viewer/src/vite-env.d.ts | 6 + packages/graph-viewer/tailwind.config.js | 34 + packages/graph-viewer/tsconfig.json | 26 + packages/graph-viewer/vite.config.ts | 33 + 26 files changed, 7926 insertions(+), 3 deletions(-) create mode 100644 automem/api/viewer.py create mode 100644 packages/graph-viewer/index.html create mode 100644 packages/graph-viewer/package-lock.json create mode 100644 packages/graph-viewer/package.json create mode 100644 packages/graph-viewer/postcss.config.js create mode 100644 packages/graph-viewer/src/App.tsx create mode 100644 packages/graph-viewer/src/api/client.ts create mode 100644 packages/graph-viewer/src/components/FilterPanel.tsx create mode 100644 packages/graph-viewer/src/components/GraphCanvas.tsx create mode 100644 packages/graph-viewer/src/components/Inspector.tsx create mode 100644 packages/graph-viewer/src/components/SearchBar.tsx create mode 100644 packages/graph-viewer/src/components/StatsBar.tsx create mode 100644 packages/graph-viewer/src/hooks/useAuth.ts create mode 100644 packages/graph-viewer/src/hooks/useForceLayout.ts create mode 100644 packages/graph-viewer/src/hooks/useGraphData.ts create mode 100644 packages/graph-viewer/src/index.css create mode 100644 packages/graph-viewer/src/lib/types.ts create mode 100644 packages/graph-viewer/src/main.tsx create mode 100644 packages/graph-viewer/src/types/d3-force-3d.d.ts create mode 100644 packages/graph-viewer/src/vite-env.d.ts create mode 100644 packages/graph-viewer/tailwind.config.js create mode 100644 packages/graph-viewer/tsconfig.json create mode 100644 packages/graph-viewer/vite.config.ts diff --git a/.gitignore b/.gitignore index 95b03b9..6313c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ tests/benchmarks/locomo/ node_modules/ # Experiment results (promote notable runs to tests/benchmarks/results/) /tests/benchmarks/experiments/results_*/ +packages/graph-viewer/dist/ +packages/graph-viewer/node_modules/ +automem/static/viewer/ diff --git a/Dockerfile b/Dockerfile index df7dca6..18ab831 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,20 @@ -# Dockerfile - Flask API runtime image +# Dockerfile - Flask API runtime image with optional Graph Viewer +# Multi-stage build: Node.js for frontend, Python for backend + +# Stage 1: Build the Graph Viewer frontend +FROM node:20-slim AS frontend-builder + +WORKDIR /build + +# Copy package files and install dependencies +COPY packages/graph-viewer/package*.json ./ +RUN npm ci --silent + +# Copy source and build +COPY packages/graph-viewer/ ./ +RUN npm run build + +# Stage 2: Python runtime FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -6,7 +22,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -# Install system deps (none currently, but keep hook for Falkor client libs if needed) +# Install system deps RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* @@ -14,9 +30,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -# Copy the full application source into the image +# Copy the full application source COPY . . +# Copy the built frontend from stage 1 +COPY --from=frontend-builder /build/dist/ ./automem/static/viewer/ + EXPOSE 8001 CMD ["python", "app.py"] diff --git a/app.py b/app.py index acf7f5f..db9216d 100644 --- a/app.py +++ b/app.py @@ -3432,6 +3432,7 @@ def get_related_memories(memory_id: str) -> Any: from automem.api.health import create_health_blueprint from automem.api.memory import create_memory_blueprint_full from automem.api.recall import create_recall_blueprint +from automem.api.viewer import create_viewer_blueprint, is_viewer_enabled health_bp = create_health_blueprint( get_memory_graph, @@ -3538,6 +3539,12 @@ def get_related_memories(memory_id: str) -> Any: app.register_blueprint(consolidation_bp) app.register_blueprint(graph_bp) +# Register optional viewer blueprint (serves the Graph Viewer SPA) +if is_viewer_enabled(): + viewer_bp = create_viewer_blueprint() + app.register_blueprint(viewer_bp) + logger.info("Graph Viewer enabled at /viewer/") + if __name__ == "__main__": port = int(os.environ.get("PORT", "8001")) diff --git a/automem/api/viewer.py b/automem/api/viewer.py new file mode 100644 index 0000000..282442c --- /dev/null +++ b/automem/api/viewer.py @@ -0,0 +1,70 @@ +"""Viewer blueprint - serves the Graph Viewer SPA. + +The viewer is an optional feature that can be enabled by setting +ENABLE_GRAPH_VIEWER=true. When enabled, it serves the pre-built +React application at /viewer/. + +Since the viewer runs on the same origin as the API, it doesn't need +CORS and can access the API directly. Authentication is handled via +a token passed in the URL hash (client-side only, never sent to server). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from flask import Blueprint, Response, send_from_directory + + +def create_viewer_blueprint() -> Blueprint: + """Create the viewer blueprint for serving the Graph Viewer SPA.""" + + # Find the static files directory + static_dir = Path(__file__).parent.parent / "static" / "viewer" + + bp = Blueprint( + "viewer", + __name__, + url_prefix="/viewer", + static_folder=str(static_dir), + static_url_path="/static", + ) + + @bp.route("/") + @bp.route("/") + def serve_viewer(path: str = "index.html") -> Any: + """Serve the viewer SPA. + + For any path under /viewer/, serve the corresponding file from + the static directory. If the file doesn't exist, serve index.html + to support client-side routing. + """ + if not static_dir.exists(): + return Response( + "Graph Viewer not installed. Run 'npm run build' in packages/graph-viewer/", + status=404, + mimetype="text/plain", + ) + + # Check if the requested file exists + file_path = static_dir / path + if file_path.is_file(): + return send_from_directory(static_dir, path) + + # For SPA routing, always serve index.html for unknown paths + return send_from_directory(static_dir, "index.html") + + @bp.route("/assets/") + def serve_assets(filename: str) -> Any: + """Serve static assets (JS, CSS, images).""" + assets_dir = static_dir / "assets" + return send_from_directory(assets_dir, filename) + + return bp + + +def is_viewer_enabled() -> bool: + """Check if the graph viewer is enabled.""" + return os.environ.get("ENABLE_GRAPH_VIEWER", "true").lower() in ("true", "1", "yes") diff --git a/packages/graph-viewer/index.html b/packages/graph-viewer/index.html new file mode 100644 index 0000000..c2186f2 --- /dev/null +++ b/packages/graph-viewer/index.html @@ -0,0 +1,23 @@ + + + + + + + AutoMem Graph Viewer + + + +
+ + + diff --git a/packages/graph-viewer/package-lock.json b/packages/graph-viewer/package-lock.json new file mode 100644 index 0000000..ccce3c8 --- /dev/null +++ b/packages/graph-viewer/package-lock.json @@ -0,0 +1,5897 @@ +{ + "name": "@automem/graph-viewer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@automem/graph-viewer", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.1", + "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.4", + "@react-three/drei": "^9.117.3", + "@react-three/fiber": "^8.17.10", + "@react-three/postprocessing": "^2.16.3", + "@tanstack/react-query": "^5.62.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "d3-force-3d": "^3.0.5", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-resizable-panels": "^2.1.7", + "tailwind-merge": "^2.5.5", + "three": "^0.170.0" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/node": "^22.10.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/three": "^0.170.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.12.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.2", + "typescript-eslint": "^8.15.0", + "vite": "^6.0.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@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.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "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/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.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.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "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.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "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.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "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.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "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.1", + "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.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "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.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "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/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz", + "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.122.0", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz", + "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@react-spring/three": "~9.7.5", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^2.9.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "react-composer": "^5.0.3", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.8", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^8", + "react": "^18", + "react-dom": "^18", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@react-three/postprocessing": { + "version": "2.19.1", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-2.19.1.tgz", + "integrity": "sha512-7P25LOSToH/I6b3UipNK17IIFlX4FDUmWcaomfwu82+CzhXTOz8Fcc1ZXEZ7vFA/5Fr/2peNlXgXZJvoa+aCdA==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "maath": "^0.6.0", + "n8ao": "^1.6.6", + "postprocessing": "^6.32.1", + "three-stdlib": "^2.23.4" + }, + "peerDependencies": { + "@react-three/fiber": "^8.0", + "react": "^18.0", + "three": ">= 0.138.0" + } + }, + "node_modules/@react-three/postprocessing/node_modules/maath": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz", + "integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.144.0", + "three": ">=0.144.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "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/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "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": "22.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", + "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.170.0.tgz", + "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.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.49.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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", + "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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", + "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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.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.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.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/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.67", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.67.tgz", + "integrity": "sha512-uk53+2ECGUkWoDFez/hymwpRfdgdIn6y1ref70fEecGMe5607f4sozNFgBk0oxlr7j2CRGWBEc3IBYMmFdGGTQ==", + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "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/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", + "integrity": "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "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/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/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/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/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.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "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.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "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/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "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/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-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/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.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/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/n8ao": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz", + "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postprocessing": { + "version": "6.38.0", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.38.0.tgz", + "integrity": "sha512-tisx8XN/PWTL3uXz2mt8bjlMS1wiOUSCK3ixi4zjwUCFmP8XW8hNhXwrxwd2zf2VmCyCQ3GUaLm7GLnkkBbDsQ==", + "license": "Zlib", + "peerDependencies": { + "three": ">= 0.157.0 < 0.182.0" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/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": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.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-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz", + "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/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.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "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.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "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==", + "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/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "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/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "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/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "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/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "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.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.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": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "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/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "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.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "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/packages/graph-viewer/package.json b/packages/graph-viewer/package.json new file mode 100644 index 0000000..cbe5d6f --- /dev/null +++ b/packages/graph-viewer/package.json @@ -0,0 +1,50 @@ +{ + "name": "@automem/graph-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.1", + "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.4", + "@react-three/drei": "^9.117.3", + "@react-three/fiber": "^8.17.10", + "@react-three/postprocessing": "^2.16.3", + "@tanstack/react-query": "^5.62.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "d3-force-3d": "^3.0.5", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-resizable-panels": "^2.1.7", + "tailwind-merge": "^2.5.5", + "three": "^0.170.0" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/node": "^22.10.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/three": "^0.170.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.12.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.2", + "typescript-eslint": "^8.15.0", + "vite": "^6.0.3" + } +} diff --git a/packages/graph-viewer/postcss.config.js b/packages/graph-viewer/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/packages/graph-viewer/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx new file mode 100644 index 0000000..39df626 --- /dev/null +++ b/packages/graph-viewer/src/App.tsx @@ -0,0 +1,134 @@ +import { useState, useCallback } from 'react' +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' +import { useGraphSnapshot } from './hooks/useGraphData' +import { useAuth } from './hooks/useAuth' +import { GraphCanvas } from './components/GraphCanvas' +import { Inspector } from './components/Inspector' +import { SearchBar } from './components/SearchBar' +import { FilterPanel } from './components/FilterPanel' +import { TokenPrompt } from './components/TokenPrompt' +import { StatsBar } from './components/StatsBar' +import type { GraphNode, FilterState } from './lib/types' + +export default function App() { + const { setToken, isAuthenticated } = useAuth() + const [selectedNode, setSelectedNode] = useState(null) + const [hoveredNode, setHoveredNode] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [filters, setFilters] = useState({ + types: [], + minImportance: 0, + maxNodes: 500, + }) + + const { data, isLoading, error, refetch } = useGraphSnapshot({ + limit: filters.maxNodes, + minImportance: filters.minImportance, + types: filters.types.length > 0 ? filters.types : undefined, + enabled: isAuthenticated, + }) + + const handleNodeSelect = useCallback((node: GraphNode | null) => { + setSelectedNode(node) + }, []) + + const handleNodeHover = useCallback((node: GraphNode | null) => { + setHoveredNode(node) + }, []) + + const handleSearch = useCallback((term: string) => { + setSearchTerm(term) + }, []) + + const handleFilterChange = useCallback((newFilters: Partial) => { + setFilters(prev => ({ ...prev, ...newFilters })) + }, []) + + if (!isAuthenticated) { + return + } + + return ( +
+ {/* Top Bar */} +
+
+
+ AM +
+

+ AutoMem +

+
+ + + + + + +
+ + {/* Main Content */} + + {/* Graph Canvas */} + +
+ {isLoading && ( +
+
+
+ Loading memories... +
+
+ )} + + {error && ( +
+
+
Connection Error
+
{(error as Error).message}
+ +
+
+ )} + + +
+ + + {/* Resize Handle */} + + + {/* Inspector Panel */} + + setSelectedNode(null)} + onNavigate={handleNodeSelect} + /> + + +
+ ) +} diff --git a/packages/graph-viewer/src/api/client.ts b/packages/graph-viewer/src/api/client.ts new file mode 100644 index 0000000..fef611c --- /dev/null +++ b/packages/graph-viewer/src/api/client.ts @@ -0,0 +1,157 @@ +import type { GraphSnapshot, GraphNeighbors, GraphStats } from '../lib/types' + +/** + * Detect if running in embedded mode (served from /viewer/ on same origin). + * In embedded mode, we use relative URLs and get token from URL hash. + */ +function isEmbeddedMode(): boolean { + return window.location.pathname.startsWith('/viewer') +} + +/** + * Get token from URL hash (e.g., /viewer/#token=xxx). + * This keeps the token client-side only, never sent to server in URL. + */ +function getTokenFromHash(): string | null { + const hash = window.location.hash + if (!hash) return null + const params = new URLSearchParams(hash.slice(1)) + return params.get('token') +} + +function getApiBase(): string { + if (isEmbeddedMode()) { + // In embedded mode, use relative URL (same origin) + return '' + } + return localStorage.getItem('automem_server') || 'http://localhost:8001' +} + +function getToken(): string | null { + // Priority: URL hash > localStorage + return getTokenFromHash() || localStorage.getItem('automem_token') +} + +function getAuthHeaders(): HeadersInit { + const token = getToken() + if (!token) { + throw new Error('No API token configured') + } + return { + 'Content-Type': 'application/json', + 'X-API-Key': token, + } +} + +export function setServerConfig(serverUrl: string, token: string): void { + localStorage.setItem('automem_server', serverUrl) + localStorage.setItem('automem_token', token) +} + +export function getServerConfig(): { serverUrl: string; token: string } | null { + // In embedded mode, check for hash token + if (isEmbeddedMode()) { + const hashToken = getTokenFromHash() + if (hashToken) { + return { serverUrl: window.location.origin, token: hashToken } + } + } + + const serverUrl = localStorage.getItem('automem_server') + const token = localStorage.getItem('automem_token') + if (!serverUrl || !token) return null + return { serverUrl, token } +} + +export function isAuthenticated(): boolean { + return !!getToken() +} + +async function handleResponse(response: Response): Promise { + if (!response.ok) { + const text = await response.text() + let message = `API error: ${response.status}` + try { + const json = JSON.parse(text) + message = json.description || json.error || message + } catch { + message = text || message + } + throw new Error(message) + } + return response.json() +} + +export interface SnapshotParams { + limit?: number + minImportance?: number + types?: string[] + since?: string +} + +export async function fetchGraphSnapshot(params: SnapshotParams = {}): Promise { + const searchParams = new URLSearchParams() + + if (params.limit) searchParams.set('limit', String(params.limit)) + if (params.minImportance) searchParams.set('min_importance', String(params.minImportance)) + if (params.types?.length) searchParams.set('types', params.types.join(',')) + if (params.since) searchParams.set('since', params.since) + + const url = `${getApiBase()}/graph/snapshot?${searchParams}` + const response = await fetch(url, { headers: getAuthHeaders() }) + return handleResponse(response) +} + +export interface NeighborsParams { + depth?: number + includeSemantic?: boolean + semanticLimit?: number +} + +export async function fetchGraphNeighbors( + memoryId: string, + params: NeighborsParams = {} +): Promise { + const searchParams = new URLSearchParams() + + if (params.depth) searchParams.set('depth', String(params.depth)) + if (params.includeSemantic !== undefined) { + searchParams.set('include_semantic', String(params.includeSemantic)) + } + if (params.semanticLimit) searchParams.set('semantic_limit', String(params.semanticLimit)) + + const url = `${getApiBase()}/graph/neighbors/${memoryId}?${searchParams}` + const response = await fetch(url, { headers: getAuthHeaders() }) + return handleResponse(response) +} + +export async function fetchGraphStats(): Promise { + const response = await fetch(`${getApiBase()}/graph/stats`, { headers: getAuthHeaders() }) + return handleResponse(response) +} + +export async function updateMemory( + memoryId: string, + updates: { importance?: number; tags?: string[]; content?: string } +): Promise { + const response = await fetch(`${getApiBase()}/memory/${memoryId}`, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify(updates), + }) + await handleResponse(response) +} + +export async function deleteMemory(memoryId: string): Promise { + const response = await fetch(`${getApiBase()}/memory/${memoryId}`, { + method: 'DELETE', + headers: getAuthHeaders(), + }) + await handleResponse(response) +} + +export async function checkHealth(serverUrl?: string): Promise<{ status: string }> { + const base = serverUrl || getApiBase() + const response = await fetch(`${base}/health`) + return handleResponse(response) +} diff --git a/packages/graph-viewer/src/components/FilterPanel.tsx b/packages/graph-viewer/src/components/FilterPanel.tsx new file mode 100644 index 0000000..19bb939 --- /dev/null +++ b/packages/graph-viewer/src/components/FilterPanel.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react' +import { Filter, ChevronDown, Check } from 'lucide-react' +import type { FilterState, MemoryType } from '../lib/types' + +const MEMORY_TYPES: MemoryType[] = [ + 'Decision', + 'Pattern', + 'Preference', + 'Style', + 'Habit', + 'Insight', + 'Context', + 'Memory', +] + +interface FilterPanelProps { + filters: FilterState + onChange: (filters: Partial) => void + typeColors?: Record +} + +export function FilterPanel({ filters, onChange, typeColors = {} }: FilterPanelProps) { + const [isOpen, setIsOpen] = useState(false) + + const toggleType = (type: MemoryType) => { + const types = filters.types.includes(type) + ? filters.types.filter((t) => t !== type) + : [...filters.types, type] + onChange({ types }) + } + + const selectedCount = filters.types.length + + return ( +
+ + + {isOpen && ( + <> + {/* Backdrop */} +
setIsOpen(false)} + /> + + {/* Dropdown */} +
+
+ Memory Types +
+
+ {MEMORY_TYPES.map((type) => { + const isSelected = filters.types.includes(type) + const color = typeColors[type] || '#94A3B8' + + return ( + + ) + })} +
+ +
+
+ Importance +
+ + onChange({ minImportance: parseFloat(e.target.value) }) + } + className="w-full accent-blue-500" + /> +
+ All + {filters.minImportance.toFixed(1)} + Critical +
+
+ +
+
+ Max Nodes +
+
+ {[100, 250, 500, 1000].map((n) => ( + + ))} +
+
+ + {selectedCount > 0 && ( + + )} +
+ + )} +
+ ) +} diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx new file mode 100644 index 0000000..ce48bb5 --- /dev/null +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -0,0 +1,344 @@ +import { useRef, useMemo, useState, useCallback } from 'react' +import { Canvas, useFrame } from '@react-three/fiber' +import { OrbitControls, Text, Billboard, Line } from '@react-three/drei' +import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing' +import * as THREE from 'three' +import { useForceLayout } from '../hooks/useForceLayout' +import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' + +interface GraphCanvasProps { + nodes: GraphNode[] + edges: GraphEdge[] + selectedNode: GraphNode | null + hoveredNode: GraphNode | null + searchTerm: string + onNodeSelect: (node: GraphNode | null) => void + onNodeHover: (node: GraphNode | null) => void +} + +export function GraphCanvas({ + nodes, + edges, + selectedNode, + hoveredNode, + searchTerm, + onNodeSelect, + onNodeHover, +}: GraphCanvasProps) { + return ( + + + + ) +} + +function Scene({ + nodes, + edges, + selectedNode, + hoveredNode, + searchTerm, + onNodeSelect, + onNodeHover, +}: GraphCanvasProps) { + const { nodes: layoutNodes, isSimulating } = useForceLayout({ nodes, edges }) + const [autoRotate, setAutoRotate] = useState(true) + const groupRef = useRef(null) + + // Create node lookup for edges + const nodeById = useMemo( + () => new Map(layoutNodes.map((n) => [n.id, n])), + [layoutNodes] + ) + + // Filter nodes based on search + const searchLower = searchTerm.toLowerCase() + const matchingIds = useMemo(() => { + if (!searchTerm) return new Set() + return new Set( + layoutNodes + .filter( + (n) => + n.content.toLowerCase().includes(searchLower) || + n.tags.some((t) => t.toLowerCase().includes(searchLower)) || + n.type.toLowerCase().includes(searchLower) + ) + .map((n) => n.id) + ) + }, [layoutNodes, searchLower, searchTerm]) + + // Get connected node IDs when a node is selected + const connectedIds = useMemo(() => { + if (!selectedNode) return new Set() + const ids = new Set([selectedNode.id]) + edges.forEach((e) => { + if (e.source === selectedNode.id) ids.add(e.target) + if (e.target === selectedNode.id) ids.add(e.source) + }) + return ids + }, [selectedNode, edges]) + + // Stop auto-rotate on user interaction + const handleInteractionStart = useCallback(() => { + setAutoRotate(false) + }, []) + + return ( + <> + {/* Ambient lighting */} + + + + + {/* Camera controls */} + + + {/* Graph content */} + + {/* Edges */} + {edges.map((edge) => { + const sourceNode = nodeById.get(edge.source) + const targetNode = nodeById.get(edge.target) + if (!sourceNode || !targetNode) return null + + const isHighlighted = + selectedNode && + (edge.source === selectedNode.id || edge.target === selectedNode.id) + + const isDimmed = + selectedNode && + !connectedIds.has(edge.source) && + !connectedIds.has(edge.target) + + return ( + + ) + })} + + {/* Nodes */} + {layoutNodes.map((node) => { + const isSelected = selectedNode?.id === node.id + const isHovered = hoveredNode?.id === node.id + const isSearchMatch = !!searchTerm && matchingIds.has(node.id) + const isDimmed = !!( + (selectedNode && !connectedIds.has(node.id)) || + (searchTerm && !matchingIds.has(node.id)) + ) + + return ( + onNodeSelect(isSelected ? null : node)} + onHover={(hovered) => onNodeHover(hovered ? node : null)} + /> + ) + })} + + + {/* Post-processing effects */} + + + + + + ) +} + +interface NodeSphereProps { + node: SimulationNode + isSelected: boolean + isHovered: boolean + isSearchMatch: boolean + isDimmed: boolean + onSelect: () => void + onHover: (hovered: boolean) => void +} + +function NodeSphere({ + node, + isSelected, + isHovered, + isSearchMatch, + isDimmed, + onSelect, + onHover, +}: NodeSphereProps) { + const meshRef = useRef(null) + const [scale, setScale] = useState(1) + + // Animate scale on hover/select + useFrame((_, delta) => { + if (!meshRef.current) return + + const targetScale = isSelected ? 1.5 : isHovered ? 1.2 : 1 + const newScale = THREE.MathUtils.lerp(scale, targetScale, delta * 10) + setScale(newScale) + meshRef.current.scale.setScalar(newScale) + }) + + // Pulsing animation for search matches + useFrame(({ clock }) => { + if (!meshRef.current || !isSearchMatch) return + const pulse = 1 + Math.sin(clock.elapsedTime * 4) * 0.15 + meshRef.current.scale.setScalar(scale * pulse) + }) + + const color = useMemo(() => new THREE.Color(node.color), [node.color]) + const emissiveIntensity = isSelected ? 0.8 : isHovered ? 0.5 : isSearchMatch ? 0.6 : 0.2 + const opacity = isDimmed ? 0.15 : node.opacity + + // Truncate content for label + const label = useMemo(() => { + const text = node.content.slice(0, 40) + return text.length < node.content.length ? text + '...' : text + }, [node.content]) + + return ( + + {/* Node sphere */} + { + e.stopPropagation() + onSelect() + }} + onPointerOver={(e) => { + e.stopPropagation() + onHover(true) + document.body.style.cursor = 'pointer' + }} + onPointerOut={() => { + onHover(false) + document.body.style.cursor = 'default' + }} + > + + + + + {/* Outer glow ring for selected/hovered */} + {(isSelected || isHovered) && ( + + + + + )} + + {/* Label (only show when hovered or selected) */} + {(isHovered || isSelected) && ( + + + {label} + + + {node.type} + + + )} + + ) +} + +interface EdgeLineProps { + source: SimulationNode + target: SimulationNode + color: string + strength: number + isHighlighted: boolean + isDimmed: boolean +} + +function EdgeLine({ + source, + target, + color, + strength, + isHighlighted, + isDimmed, +}: EdgeLineProps) { + const points = useMemo( + () => [ + new THREE.Vector3(source.x ?? 0, source.y ?? 0, source.z ?? 0), + new THREE.Vector3(target.x ?? 0, target.y ?? 0, target.z ?? 0), + ], + [source.x, source.y, source.z, target.x, target.y, target.z] + ) + + const lineWidth = isHighlighted ? 2 : Math.max(0.5, strength * 1.5) + const opacity = isDimmed ? 0.05 : isHighlighted ? 0.8 : 0.3 + + return ( + + ) +} diff --git a/packages/graph-viewer/src/components/Inspector.tsx b/packages/graph-viewer/src/components/Inspector.tsx new file mode 100644 index 0000000..28f7a93 --- /dev/null +++ b/packages/graph-viewer/src/components/Inspector.tsx @@ -0,0 +1,303 @@ +import { useState } from 'react' +import { X, Clock, Tag, ArrowRight, Sparkles, Edit2, Save, Trash2 } from 'lucide-react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useGraphNeighbors } from '../hooks/useGraphData' +import { updateMemory, deleteMemory } from '../api/client' +import type { GraphNode } from '../lib/types' + +interface InspectorProps { + node: GraphNode | null + onClose: () => void + onNavigate: (node: GraphNode) => void +} + +export function Inspector({ node, onClose, onNavigate }: InspectorProps) { + const [isEditing, setIsEditing] = useState(false) + const [editedImportance, setEditedImportance] = useState(0) + const queryClient = useQueryClient() + + const { data: neighbors } = useGraphNeighbors(node?.id ?? null, { + depth: 1, + includeSemantic: true, + semanticLimit: 5, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, updates }: { id: string; updates: { importance?: number } }) => + updateMemory(id, updates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['graph'] }) + setIsEditing(false) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteMemory(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['graph'] }) + onClose() + }, + }) + + const handleStartEdit = () => { + if (node) { + setEditedImportance(node.importance) + setIsEditing(true) + } + } + + const handleSave = () => { + if (node) { + updateMutation.mutate({ + id: node.id, + updates: { importance: editedImportance }, + }) + } + } + + const handleDelete = () => { + if (node && confirm('Delete this memory? This cannot be undone.')) { + deleteMutation.mutate(node.id) + } + } + + if (!node) { + return ( +
+
+ +
+

No Memory Selected

+

+ Click a node in the graph to view its details +

+
+ ) + } + + const formattedDate = new Date(node.timestamp).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + + return ( +
+ {/* Header */} +
+
+
+
+
+ {node.type} + + {(node.confidence * 100).toFixed(0)}% conf + +
+
{node.id}
+
+ +
+
+ + {/* Content */} +
+ {/* Content */} +
+

+ Content +

+

+ {node.content} +

+
+ + {/* Tags */} + {node.tags.length > 0 && ( +
+

+ Tags +

+
+ {node.tags.map((tag) => ( + + + {tag} + + ))} +
+
+ )} + + {/* Importance */} +
+
+

+ Importance +

+ {!isEditing ? ( + + ) : ( + + )} +
+ {isEditing ? ( +
+ setEditedImportance(parseFloat(e.target.value))} + className="w-full accent-blue-500" + /> +
+ {editedImportance.toFixed(2)} +
+
+ ) : ( +
+
+
+ )} +
+ Low + {(node.importance * 100).toFixed(0)}% + Critical +
+
+ + {/* Timestamp */} +
+

+ Created +

+
+ + {formattedDate} +
+
+ + {/* Graph Relationships */} + {neighbors?.edges && neighbors.edges.length > 0 && ( +
+

+ Relationships ({neighbors.edges.length}) +

+
+ {neighbors.graph_neighbors.slice(0, 5).map((neighbor) => { + const edge = neighbors.edges.find( + (e) => + (e.source === node.id && e.target === neighbor.id) || + (e.target === node.id && e.source === neighbor.id) + ) + + return ( + + ) + })} +
+
+ )} + + {/* Semantic Neighbors */} + {neighbors?.semantic_neighbors && neighbors.semantic_neighbors.length > 0 && ( +
+

+ Similar Memories +

+
+ {neighbors.semantic_neighbors.map((neighbor) => ( + + ))} +
+
+ )} +
+ + {/* Footer Actions */} +
+ +
+
+ ) +} diff --git a/packages/graph-viewer/src/components/SearchBar.tsx b/packages/graph-viewer/src/components/SearchBar.tsx new file mode 100644 index 0000000..2c2e0e3 --- /dev/null +++ b/packages/graph-viewer/src/components/SearchBar.tsx @@ -0,0 +1,54 @@ +import { useState, useEffect, useCallback } from 'react' +import { Search, X } from 'lucide-react' + +interface SearchBarProps { + value: string + onChange: (value: string) => void + className?: string +} + +export function SearchBar({ value, onChange, className = '' }: SearchBarProps) { + const [localValue, setLocalValue] = useState(value) + + // Debounce the onChange callback + useEffect(() => { + const timer = setTimeout(() => { + onChange(localValue) + }, 300) + + return () => clearTimeout(timer) + }, [localValue, onChange]) + + // Sync external value changes + useEffect(() => { + setLocalValue(value) + }, [value]) + + const handleClear = useCallback(() => { + setLocalValue('') + onChange('') + }, [onChange]) + + return ( +
+
+ +
+ setLocalValue(e.target.value)} + placeholder="Search memories..." + className="w-full pl-9 pr-9 py-2 bg-black/30 border border-white/10 rounded-lg focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/50 outline-none transition-colors text-sm text-slate-100 placeholder-slate-500" + /> + {localValue && ( + + )} +
+ ) +} diff --git a/packages/graph-viewer/src/components/StatsBar.tsx b/packages/graph-viewer/src/components/StatsBar.tsx new file mode 100644 index 0000000..ad36ca4 --- /dev/null +++ b/packages/graph-viewer/src/components/StatsBar.tsx @@ -0,0 +1,53 @@ +import { Database, GitBranch } from 'lucide-react' + +interface StatsBarProps { + stats?: { + total_nodes: number + total_edges: number + returned_nodes: number + returned_edges: number + sampled: boolean + sample_ratio: number + } + isLoading: boolean +} + +export function StatsBar({ stats, isLoading }: StatsBarProps) { + if (isLoading || !stats) { + return ( +
+
+ + Loading... +
+
+ ) + } + + return ( +
+
+ + + {stats.returned_nodes.toLocaleString()} + {stats.sampled && ( + + {' '}/ {stats.total_nodes.toLocaleString()} + + )} + +
+
+ + + {stats.returned_edges.toLocaleString()} + {stats.sampled && ( + + {' '}/ {stats.total_edges.toLocaleString()} + + )} + +
+
+ ) +} diff --git a/packages/graph-viewer/src/hooks/useAuth.ts b/packages/graph-viewer/src/hooks/useAuth.ts new file mode 100644 index 0000000..fd20b98 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useAuth.ts @@ -0,0 +1,57 @@ +import { useState, useCallback, useEffect } from 'react' +import { isAuthenticated as checkAuth } from '../api/client' + +const TOKEN_KEY = 'automem_token' +const SERVER_KEY = 'automem_server' + +export function useAuth() { + const [token, setTokenState] = useState(() => { + return localStorage.getItem(TOKEN_KEY) + }) + const [serverUrl, setServerUrlState] = useState(() => { + return localStorage.getItem(SERVER_KEY) + }) + + const setToken = useCallback((newToken: string) => { + localStorage.setItem(TOKEN_KEY, newToken) + setTokenState(newToken) + }, []) + + const setServerUrl = useCallback((url: string) => { + localStorage.setItem(SERVER_KEY, url) + setServerUrlState(url) + }, []) + + const clearAuth = useCallback(() => { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(SERVER_KEY) + setTokenState(null) + setServerUrlState(null) + }, []) + + // Sync with localStorage changes from other tabs + useEffect(() => { + const handleStorage = (e: StorageEvent) => { + if (e.key === TOKEN_KEY) { + setTokenState(e.newValue) + } + if (e.key === SERVER_KEY) { + setServerUrlState(e.newValue) + } + } + window.addEventListener('storage', handleStorage) + return () => window.removeEventListener('storage', handleStorage) + }, []) + + // Check authentication using client's method (supports hash tokens) + const isAuthenticated = checkAuth() + + return { + token, + serverUrl, + setToken, + setServerUrl, + clearAuth, + isAuthenticated, + } +} diff --git a/packages/graph-viewer/src/hooks/useForceLayout.ts b/packages/graph-viewer/src/hooks/useForceLayout.ts new file mode 100644 index 0000000..13c3f7e --- /dev/null +++ b/packages/graph-viewer/src/hooks/useForceLayout.ts @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import { + forceSimulation, + forceLink, + forceManyBody, + forceCenter, + forceCollide, + forceRadial, +} from 'd3-force-3d' +import type { GraphNode, GraphEdge, SimulationNode, SimulationLink } from '../lib/types' + +interface UseForceLayoutOptions { + nodes: GraphNode[] + edges: GraphEdge[] + strength?: number + centerStrength?: number + collisionRadius?: number +} + +interface LayoutState { + nodes: SimulationNode[] + isSimulating: boolean +} + +export function useForceLayout({ + nodes, + edges, + strength = -100, + centerStrength = 0.05, + collisionRadius = 2, +}: UseForceLayoutOptions): LayoutState & { reheat: () => void } { + const simulationRef = useRef | null>(null) + const [layoutNodes, setLayoutNodes] = useState([]) + const [isSimulating, setIsSimulating] = useState(false) + + // Initialize simulation when nodes/edges change + useEffect(() => { + if (nodes.length === 0) { + setLayoutNodes([]) + return + } + + // Create simulation nodes with initial positions + const simNodes: SimulationNode[] = nodes.map((node, i) => { + // Use Fibonacci sphere for initial distribution + const phi = Math.acos(1 - (2 * (i + 0.5)) / nodes.length) + const theta = Math.PI * (1 + Math.sqrt(5)) * i + const radius = 50 + (1 - node.importance) * 100 // High importance = center + + return { + ...node, + x: radius * Math.sin(phi) * Math.cos(theta), + y: radius * Math.sin(phi) * Math.sin(theta), + z: radius * Math.cos(phi), + vx: 0, + vy: 0, + vz: 0, + } + }) + + // Create node lookup + const nodeById = new Map(simNodes.map((n) => [n.id, n])) + + // Create links + const links: SimulationLink[] = edges + .filter((e) => nodeById.has(e.source) && nodeById.has(e.target)) + .map((e) => ({ + source: e.source, + target: e.target, + strength: e.strength, + type: e.type, + })) + + // Stop existing simulation + if (simulationRef.current) { + simulationRef.current.stop() + } + + // Create 3D force simulation + const simulation = forceSimulation(simNodes, 3) + .force( + 'link', + forceLink(links) + .id((d: SimulationNode) => d.id) + .distance((d: SimulationLink) => 30 + (1 - d.strength) * 50) + .strength((d: SimulationLink) => d.strength * 0.5) + ) + .force('charge', forceManyBody().strength(strength)) + .force('center', forceCenter(0, 0, 0).strength(centerStrength)) + .force( + 'collision', + forceCollide() + .radius((d: SimulationNode) => d.radius * collisionRadius) + .strength(0.7) + ) + .force( + 'radial', + forceRadial( + (d: SimulationNode) => 30 + (1 - d.importance) * 70, + 0, + 0, + 0 + ).strength(0.3) + ) + .alphaDecay(0.02) + .velocityDecay(0.3) + + simulationRef.current = simulation + setIsSimulating(true) + + // Update state on each tick + simulation.on('tick', () => { + setLayoutNodes([...simNodes]) + }) + + simulation.on('end', () => { + setIsSimulating(false) + }) + + // Run simulation for a bit then settle + simulation.alpha(1).restart() + + return () => { + simulation.stop() + } + }, [nodes, edges, strength, centerStrength, collisionRadius]) + + const reheat = useCallback(() => { + if (simulationRef.current) { + simulationRef.current.alpha(0.5).restart() + setIsSimulating(true) + } + }, []) + + return { nodes: layoutNodes, isSimulating, reheat } +} diff --git a/packages/graph-viewer/src/hooks/useGraphData.ts b/packages/graph-viewer/src/hooks/useGraphData.ts new file mode 100644 index 0000000..b542461 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useGraphData.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchGraphSnapshot, fetchGraphNeighbors, fetchGraphStats, type SnapshotParams, type NeighborsParams } from '../api/client' + +export function useGraphSnapshot(params: SnapshotParams & { enabled?: boolean } = {}) { + const { enabled = true, ...queryParams } = params + + return useQuery({ + queryKey: ['graph', 'snapshot', queryParams], + queryFn: () => fetchGraphSnapshot(queryParams), + enabled, + }) +} + +export function useGraphNeighbors(memoryId: string | null, params: NeighborsParams = {}) { + return useQuery({ + queryKey: ['graph', 'neighbors', memoryId, params], + queryFn: () => fetchGraphNeighbors(memoryId!, params), + enabled: !!memoryId, + }) +} + +export function useGraphStats(enabled = true) { + return useQuery({ + queryKey: ['graph', 'stats'], + queryFn: fetchGraphStats, + enabled, + }) +} diff --git a/packages/graph-viewer/src/index.css b/packages/graph-viewer/src/index.css new file mode 100644 index 0000000..c59fda9 --- /dev/null +++ b/packages/graph-viewer/src/index.css @@ -0,0 +1,63 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-tertiary: #1a1a24; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --border: #2d2d3a; + --accent: #3b82f6; +} + +* { + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Glass effect for panels */ +.glass { + background: rgba(18, 18, 26, 0.8); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Glow effects */ +.glow-blue { + box-shadow: 0 0 20px rgba(59, 130, 246, 0.3); +} + +.glow-green { + box-shadow: 0 0 20px rgba(16, 185, 129, 0.3); +} + +.glow-purple { + box-shadow: 0 0 20px rgba(139, 92, 246, 0.3); +} diff --git a/packages/graph-viewer/src/lib/types.ts b/packages/graph-viewer/src/lib/types.ts new file mode 100644 index 0000000..9e7f22f --- /dev/null +++ b/packages/graph-viewer/src/lib/types.ts @@ -0,0 +1,126 @@ +export interface GraphNode { + id: string + content: string + type: MemoryType + importance: number + confidence: number + tags: string[] + timestamp: string + updated_at?: string + metadata?: Record + color: string + radius: number + opacity: number + // 3D position (computed by force layout) + x?: number + y?: number + z?: number + // Velocity for force simulation + vx?: number + vy?: number + vz?: number +} + +export interface GraphEdge { + id: string + source: string + target: string + type: RelationType + strength: number + color: string + properties?: Record +} + +export type MemoryType = + | 'Decision' + | 'Pattern' + | 'Preference' + | 'Style' + | 'Habit' + | 'Insight' + | 'Context' + | 'Memory' + +export type RelationType = + | 'RELATES_TO' + | 'LEADS_TO' + | 'OCCURRED_BEFORE' + | 'PREFERS_OVER' + | 'EXEMPLIFIES' + | 'CONTRADICTS' + | 'REINFORCES' + | 'INVALIDATED_BY' + | 'EVOLVED_INTO' + | 'DERIVED_FROM' + | 'PART_OF' + +export interface GraphSnapshot { + nodes: GraphNode[] + edges: GraphEdge[] + stats: { + total_nodes: number + total_edges: number + returned_nodes: number + returned_edges: number + sampled: boolean + sample_ratio: number + } + meta: { + type_colors: Record + relation_colors: Record + query_time_ms: number + } +} + +export interface GraphNeighbors { + center: GraphNode + graph_neighbors: GraphNode[] + semantic_neighbors: (GraphNode & { similarity: number })[] + edges: GraphEdge[] + meta: { + depth: number + query_time_ms: number + } +} + +export interface GraphStats { + totals: { + nodes: number + edges: number + } + by_type: Record + by_relationship: Record + importance_distribution: { + high: number + medium: number + low: number + } + recent_activity: Array<{ + date: string + count: number + }> + meta: { + type_colors: Record + relation_colors: Record + query_time_ms: number + } +} + +export interface FilterState { + types: MemoryType[] + minImportance: number + maxNodes: number +} + +export interface SimulationNode extends GraphNode { + fx?: number | null + fy?: number | null + fz?: number | null +} + +export interface SimulationLink { + source: string | SimulationNode + target: string | SimulationNode + strength: number + type: RelationType +} diff --git a/packages/graph-viewer/src/main.tsx b/packages/graph-viewer/src/main.tsx new file mode 100644 index 0000000..de9c2b0 --- /dev/null +++ b/packages/graph-viewer/src/main.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30000, // 30 seconds + refetchOnWindowFocus: false, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/packages/graph-viewer/src/types/d3-force-3d.d.ts b/packages/graph-viewer/src/types/d3-force-3d.d.ts new file mode 100644 index 0000000..f221c52 --- /dev/null +++ b/packages/graph-viewer/src/types/d3-force-3d.d.ts @@ -0,0 +1,120 @@ +// Type declarations for d3-force-3d +declare module 'd3-force-3d' { + import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force' + + export interface Simulation3D< + NodeDatum extends SimulationNodeDatum, + LinkDatum extends SimulationLinkDatum | undefined + > { + restart(): this + stop(): this + tick(iterations?: number): this + nodes(): NodeDatum[] + nodes(nodes: NodeDatum[]): this + alpha(): number + alpha(alpha: number): this + alphaMin(): number + alphaMin(min: number): this + alphaDecay(): number + alphaDecay(decay: number): this + alphaTarget(): number + alphaTarget(target: number): this + velocityDecay(): number + velocityDecay(decay: number): this + force(name: string): any + force(name: string, force: any): this + find(x: number, y: number, z?: number, radius?: number): NodeDatum | undefined + randomSource(): () => number + randomSource(source: () => number): this + on(typenames: string): any + on(typenames: string, listener: any): this + numDimensions(): number + numDimensions(nDim: number): this + } + + export function forceSimulation( + nodes?: NodeDatum[], + numDimensions?: number + ): Simulation3D + + export function forceLink< + NodeDatum extends SimulationNodeDatum, + LinkDatum extends SimulationLinkDatum + >( + links?: LinkDatum[] + ): { + (alpha: number): void + initialize(nodes: NodeDatum[], random: () => number): void + links(): LinkDatum[] + links(links: LinkDatum[]): any + id(): (node: NodeDatum, i: number, nodes: NodeDatum[]) => any + id(id: (node: NodeDatum, i: number, nodes: NodeDatum[]) => any): any + iterations(): number + iterations(iterations: number): any + strength(): (link: LinkDatum, i: number, links: LinkDatum[]) => number + strength(strength: number | ((link: LinkDatum, i: number, links: LinkDatum[]) => number)): any + distance(): (link: LinkDatum, i: number, links: LinkDatum[]) => number + distance(distance: number | ((link: LinkDatum, i: number, links: LinkDatum[]) => number)): any + } + + export function forceManyBody(): { + (alpha: number): void + initialize(nodes: NodeDatum[], random: () => number): void + strength(): (d: NodeDatum, i: number, data: NodeDatum[]) => number + strength(strength: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): any + distanceMin(): number + distanceMin(distance: number): any + distanceMax(): number + distanceMax(distance: number): any + theta(): number + theta(theta: number): any + } + + export function forceCenter( + x?: number, + y?: number, + z?: number + ): { + (alpha: number): void + initialize(nodes: NodeDatum[], random: () => number): void + x(): number + x(x: number): any + y(): number + y(y: number): any + z(): number + z(z: number): any + strength(): number + strength(strength: number): any + } + + export function forceCollide(): { + (alpha: number): void + initialize(nodes: NodeDatum[], random: () => number): void + iterations(): number + iterations(iterations: number): any + strength(): number + strength(strength: number): any + radius(): (node: NodeDatum, i: number, nodes: NodeDatum[]) => number + radius(radius: number | ((node: NodeDatum, i: number, nodes: NodeDatum[]) => number)): any + } + + export function forceRadial( + radius?: number | ((node: NodeDatum, i: number, nodes: NodeDatum[]) => number), + x?: number, + y?: number, + z?: number + ): { + (alpha: number): void + initialize(nodes: NodeDatum[], random: () => number): void + strength(): (node: NodeDatum, i: number, nodes: NodeDatum[]) => number + strength(strength: number | ((node: NodeDatum, i: number, nodes: NodeDatum[]) => number)): any + radius(): (node: NodeDatum, i: number, nodes: NodeDatum[]) => number + radius(radius: number | ((node: NodeDatum, i: number, nodes: NodeDatum[]) => number)): any + x(): number + x(x: number): any + y(): number + y(y: number): any + z(): number + z(z: number): any + } +} diff --git a/packages/graph-viewer/src/vite-env.d.ts b/packages/graph-viewer/src/vite-env.d.ts new file mode 100644 index 0000000..b0c27ec --- /dev/null +++ b/packages/graph-viewer/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module '*.css' { + const content: string + export default content +} diff --git a/packages/graph-viewer/tailwind.config.js b/packages/graph-viewer/tailwind.config.js new file mode 100644 index 0000000..674c98b --- /dev/null +++ b/packages/graph-viewer/tailwind.config.js @@ -0,0 +1,34 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + darkMode: 'class', + theme: { + extend: { + colors: { + // Memory type colors + decision: '#3B82F6', + pattern: '#10B981', + preference: '#8B5CF6', + style: '#EC4899', + habit: '#F59E0B', + insight: '#F97316', + context: '#6B7280', + memory: '#94A3B8', + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'glow': 'glow 2s ease-in-out infinite alternate', + }, + keyframes: { + glow: { + '0%': { boxShadow: '0 0 5px currentColor' }, + '100%': { boxShadow: '0 0 20px currentColor' }, + }, + }, + }, + }, + plugins: [], +} diff --git a/packages/graph-viewer/tsconfig.json b/packages/graph-viewer/tsconfig.json new file mode 100644 index 0000000..10f71ea --- /dev/null +++ b/packages/graph-viewer/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/packages/graph-viewer/vite.config.ts b/packages/graph-viewer/vite.config.ts new file mode 100644 index 0000000..45632b1 --- /dev/null +++ b/packages/graph-viewer/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5173, + proxy: { + '/graph': { + target: 'http://localhost:8001', + changeOrigin: true, + }, + '/recall': { + target: 'http://localhost:8001', + changeOrigin: true, + }, + '/memory': { + target: 'http://localhost:8001', + changeOrigin: true, + }, + '/health': { + target: 'http://localhost:8001', + changeOrigin: true, + }, + }, + }, +}) From 3b0f1f76e33534f3af84a21035c4e088e984f0c6 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 03:09:21 +0100 Subject: [PATCH 04/47] fix: exempt /viewer/ routes from API authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer serves static files that don't need API auth. Authentication is handled client-side via URL hash fragment (#token=xxx). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app.py b/app.py index db9216d..f9d2396 100644 --- a/app.py +++ b/app.py @@ -1117,6 +1117,11 @@ def require_api_token() -> None: if endpoint.endswith("health") or request.path == "/health": return + # Allow unauthenticated access to the graph viewer (static files) + # Token is passed via URL hash fragment (client-side only) + if request.path.startswith("/viewer"): + return + token = _extract_api_token() if token != API_TOKEN: abort(401, description="Unauthorized") From f5f404c4881970a66c4144e76946d574b5fef9bd Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 03:32:22 +0100 Subject: [PATCH 05/47] fix: set Vite base path for embedded viewer assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assets were loading from root (/) instead of /viewer/static/, causing 401 errors since those paths require authentication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/graph-viewer/vite.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/graph-viewer/vite.config.ts b/packages/graph-viewer/vite.config.ts index 45632b1..c28fd54 100644 --- a/packages/graph-viewer/vite.config.ts +++ b/packages/graph-viewer/vite.config.ts @@ -4,6 +4,8 @@ import path from 'path' export default defineConfig({ plugins: [react()], + // Base path for embedded mode - assets served from /viewer/static/ + base: '/viewer/static/', resolve: { alias: { '@': path.resolve(__dirname, './src'), From daf9a2b2564fe92d115bc13367ec1fc24ccb8833 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 04:19:02 +0100 Subject: [PATCH 06/47] feat: add hand gesture control for memory graph navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Meta Quest-style hand gesture controls using MediaPipe Hands: - Two-hand spread/pinch for zoom in/out - Two-hand rotation for orbiting camera - Two-hand pan for moving view - Hand skeleton wireframe overlay (cyan left, magenta right) - Toggle button in header to enable/disable gesture control Requires camera permission when enabled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/graph-viewer/package-lock.json | 21 ++ packages/graph-viewer/package.json | 3 + packages/graph-viewer/src/App.tsx | 32 ++ .../src/components/GraphCanvas.tsx | 72 +++- .../src/components/HandSkeletonOverlay.tsx | 185 ++++++++++ .../graph-viewer/src/hooks/useHandGestures.ts | 316 ++++++++++++++++++ 6 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 packages/graph-viewer/src/components/HandSkeletonOverlay.tsx create mode 100644 packages/graph-viewer/src/hooks/useHandGestures.ts diff --git a/packages/graph-viewer/package-lock.json b/packages/graph-viewer/package-lock.json index ccce3c8..9db9a7e 100644 --- a/packages/graph-viewer/package-lock.json +++ b/packages/graph-viewer/package-lock.json @@ -8,6 +8,9 @@ "name": "@automem/graph-viewer", "version": "0.1.0", "dependencies": { + "@mediapipe/camera_utils": "^0.3.1675466862", + "@mediapipe/drawing_utils": "^0.3.1675466124", + "@mediapipe/hands": "^0.4.1675469240", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slider": "^1.2.1", @@ -1089,6 +1092,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/camera_utils": { + "version": "0.3.1675466862", + "resolved": "https://registry.npmjs.org/@mediapipe/camera_utils/-/camera_utils-0.3.1675466862.tgz", + "integrity": "sha512-siuXBoUxWo9WL0MeAxIxvxY04bvbtdNl7uCxoJxiAiRtNnCYrurr7Vl5VYQ94P7Sq0gVq6PxIDhWWeZ/pLnSzw==", + "license": "Apache-2.0" + }, + "node_modules/@mediapipe/drawing_utils": { + "version": "0.3.1675466124", + "resolved": "https://registry.npmjs.org/@mediapipe/drawing_utils/-/drawing_utils-0.3.1675466124.tgz", + "integrity": "sha512-/IWIB/iYRMtiUKe3k7yGqvwseWHCOqzVpRDfMgZ6gv9z7EEimg6iZbRluoPbcNKHbYSxN5yOvYTzUYb8KVf22Q==", + "license": "Apache-2.0" + }, + "node_modules/@mediapipe/hands": { + "version": "0.4.1675469240", + "resolved": "https://registry.npmjs.org/@mediapipe/hands/-/hands-0.4.1675469240.tgz", + "integrity": "sha512-GxoZvL1mmhJxFxjuyj7vnC++JIuInGznHBin5c7ZSq/RbcnGyfEcJrkM/bMu5K1Mz/2Ko+vEX6/+wewmEHPrHg==", + "license": "Apache-2.0" + }, "node_modules/@mediapipe/tasks-vision": { "version": "0.10.17", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", diff --git a/packages/graph-viewer/package.json b/packages/graph-viewer/package.json index cbe5d6f..7640e70 100644 --- a/packages/graph-viewer/package.json +++ b/packages/graph-viewer/package.json @@ -10,6 +10,9 @@ "lint": "eslint ." }, "dependencies": { + "@mediapipe/camera_utils": "^0.3.1675466862", + "@mediapipe/drawing_utils": "^0.3.1675466124", + "@mediapipe/hands": "^0.4.1675469240", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slider": "^1.2.1", diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 39df626..628c058 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -10,11 +10,24 @@ import { TokenPrompt } from './components/TokenPrompt' import { StatsBar } from './components/StatsBar' import type { GraphNode, FilterState } from './lib/types' +// Hand icon SVG component +function HandIcon({ className }: { className?: string }) { + return ( + + + + + + + ) +} + export default function App() { const { setToken, isAuthenticated } = useAuth() const [selectedNode, setSelectedNode] = useState(null) const [hoveredNode, setHoveredNode] = useState(null) const [searchTerm, setSearchTerm] = useState('') + const [gestureControlEnabled, setGestureControlEnabled] = useState(false) const [filters, setFilters] = useState({ types: [], minImportance: 0, @@ -74,6 +87,24 @@ export default function App() { /> + + {/* Gesture Control Toggle */} + {/* Main Content */} @@ -113,6 +144,7 @@ export default function App() { searchTerm={searchTerm} onNodeSelect={handleNodeSelect} onNodeHover={handleNodeHover} + gestureControlEnabled={gestureControlEnabled} />
diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index ce48bb5..2ecd665 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -1,10 +1,13 @@ -import { useRef, useMemo, useState, useCallback } from 'react' +import { useRef, useMemo, useState, useCallback, useEffect } from 'react' import { Canvas, useFrame } from '@react-three/fiber' import { OrbitControls, Text, Billboard, Line } from '@react-three/drei' import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing' import * as THREE from 'three' import { useForceLayout } from '../hooks/useForceLayout' +import { useHandGestures, GestureState } from '../hooks/useHandGestures' +import { HandSkeletonOverlay } from './HandSkeletonOverlay' import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' +import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' interface GraphCanvasProps { nodes: GraphNode[] @@ -14,6 +17,7 @@ interface GraphCanvasProps { searchTerm: string onNodeSelect: (node: GraphNode | null) => void onNodeHover: (node: GraphNode | null) => void + gestureControlEnabled?: boolean } export function GraphCanvas({ @@ -24,7 +28,13 @@ export function GraphCanvas({ searchTerm, onNodeSelect, onNodeHover, + gestureControlEnabled = false, }: GraphCanvasProps) { + // Hand gesture tracking + const { gestureState, isEnabled: gesturesActive } = useHandGestures({ + enabled: gestureControlEnabled, + }) + return ( ) } +interface SceneProps extends GraphCanvasProps { + gestureState: GestureState + gestureControlEnabled: boolean +} + function Scene({ nodes, edges, @@ -52,10 +69,13 @@ function Scene({ searchTerm, onNodeSelect, onNodeHover, -}: GraphCanvasProps) { + gestureState, + gestureControlEnabled, +}: SceneProps) { const { nodes: layoutNodes, isSimulating } = useForceLayout({ nodes, edges }) const [autoRotate, setAutoRotate] = useState(true) const groupRef = useRef(null) + const controlsRef = useRef(null) // Create node lookup for edges const nodeById = useMemo( @@ -95,6 +115,46 @@ function Scene({ setAutoRotate(false) }, []) + // Apply gesture controls to camera + useEffect(() => { + if (!gestureControlEnabled || !controlsRef.current) return + if (!gestureState.isTracking || gestureState.handsDetected < 2) return + + const controls = controlsRef.current + + // Two-hand zoom: spread = zoom in, pinch = zoom out + if (Math.abs(gestureState.zoomDelta) > 0.001) { + // Dolly in/out based on hand spread + const zoomFactor = 1 - gestureState.zoomDelta * 0.5 + controls.object.position.multiplyScalar(zoomFactor) + setAutoRotate(false) + } + + // Two-hand rotation: rotate the camera around the target + if (Math.abs(gestureState.rotateDelta) > 0.01) { + // Convert rotation delta to camera orbit + const rotateSpeed = 2 + controls.object.position.applyAxisAngle( + new THREE.Vector3(0, 1, 0), + gestureState.rotateDelta * rotateSpeed + ) + setAutoRotate(false) + } + + // Two-hand pan: move target based on center movement + if ( + Math.abs(gestureState.panDelta.x) > 0.001 || + Math.abs(gestureState.panDelta.y) > 0.001 + ) { + const panSpeed = 50 + controls.target.x -= gestureState.panDelta.x * panSpeed + controls.target.y += gestureState.panDelta.y * panSpeed + setAutoRotate(false) + } + + controls.update() + }, [gestureControlEnabled, gestureState]) + return ( <> {/* Ambient lighting */} @@ -104,15 +164,21 @@ function Scene({ {/* Camera controls */} + {/* Hand skeleton overlay */} + {gestureControlEnabled && ( + + )} + {/* Graph content */} {/* Edges */} diff --git a/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx b/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx new file mode 100644 index 0000000..9ea1e45 --- /dev/null +++ b/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx @@ -0,0 +1,185 @@ +/** + * Hand Skeleton Overlay + * + * Renders 3D wireframe hands overlaid on the graph visualization. + * Uses React Three Fiber to render hand landmarks as glowing lines. + */ + +import { useMemo } from 'react' +import { Line } from '@react-three/drei' +import type { HandLandmarks, GestureState } from '../hooks/useHandGestures' + +// Hand skeleton connections (pairs of landmark indices) +const HAND_CONNECTIONS = [ + // Thumb + [0, 1], [1, 2], [2, 3], [3, 4], + // Index finger + [0, 5], [5, 6], [6, 7], [7, 8], + // Middle finger + [0, 9], [9, 10], [10, 11], [11, 12], + // Ring finger + [0, 13], [13, 14], [14, 15], [15, 16], + // Pinky + [0, 17], [17, 18], [18, 19], [19, 20], + // Palm + [5, 9], [9, 13], [13, 17], [0, 17], +] + +interface HandSkeletonProps { + hand: HandLandmarks + color: string + opacity?: number + scale?: number +} + +// Convert MediaPipe normalized coords (0-1) to Three.js world coords +function landmarkToWorld( + landmark: { x: number; y: number; z: number }, + scale: number = 10 +): [number, number, number] { + // MediaPipe: x=0-1 (left-right), y=0-1 (top-bottom), z=depth + // Three.js: x=-5 to 5, y=-5 to 5, z=depth + return [ + (landmark.x - 0.5) * scale, + -(landmark.y - 0.5) * scale, // Flip Y + -landmark.z * scale * 2, // Z comes toward camera + ] +} + +function HandSkeleton({ hand, color, opacity = 0.8, scale = 10 }: HandSkeletonProps) { + const lines = useMemo(() => { + return HAND_CONNECTIONS.map(([i, j], idx) => { + const start = landmarkToWorld(hand.landmarks[i], scale) + const end = landmarkToWorld(hand.landmarks[j], scale) + return { start, end, key: idx } + }) + }, [hand.landmarks, scale]) + + const jointPositions = useMemo(() => { + return hand.landmarks.map((lm, idx) => ({ + position: landmarkToWorld(lm, scale), + key: idx, + // Fingertips get larger spheres + isFingertip: [4, 8, 12, 16, 20].includes(idx), + })) + }, [hand.landmarks, scale]) + + return ( + + {/* Skeleton lines */} + {lines.map(({ start, end, key }) => ( + + ))} + + {/* Joint spheres */} + {jointPositions.map(({ position, key, isFingertip }) => ( + + + + + ))} + + ) +} + +interface GestureIndicatorProps { + gestureState: GestureState +} + +function GestureIndicator({ gestureState }: GestureIndicatorProps) { + const { handsDetected, zoomDelta, pointDirection } = gestureState + + // Pointing ray + if (pointDirection && handsDetected === 1) { + const rayStart: [number, number, number] = [ + (pointDirection.x - 0.5) * 10, + (pointDirection.y - 0.5) * 10, + 0, + ] + const rayEnd: [number, number, number] = [ + (pointDirection.x - 0.5) * 10, + (pointDirection.y - 0.5) * 10, + -50, // Ray extends into the scene + ] + + return ( + + + {/* Pointer dot */} + + + + + + ) + } + + // Two-hand zoom indicator + if (handsDetected === 2 && Math.abs(zoomDelta) > 0.01) { + const zoomColor = zoomDelta > 0 ? '#4ecdc4' : '#ff6b6b' + return ( + + + + + ) + } + + return null +} + +interface HandSkeletonOverlayProps { + gestureState: GestureState + enabled?: boolean +} + +export function HandSkeletonOverlay({ gestureState, enabled = true }: HandSkeletonOverlayProps) { + if (!enabled || !gestureState.isTracking) return null + + return ( + + {/* Left hand - cyan */} + {gestureState.leftHand && ( + + )} + + {/* Right hand - magenta */} + {gestureState.rightHand && ( + + )} + + {/* Gesture feedback */} + + + ) +} + +export default HandSkeletonOverlay diff --git a/packages/graph-viewer/src/hooks/useHandGestures.ts b/packages/graph-viewer/src/hooks/useHandGestures.ts new file mode 100644 index 0000000..c29b2e4 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandGestures.ts @@ -0,0 +1,316 @@ +/** + * Hand Gesture Recognition Hook + * + * Uses MediaPipe Hands to detect hand gestures for controlling the 3D memory graph. + * Supports Meta Quest-style gestures: + * - Two-hand spread/pinch for zoom + * - Two-hand rotation for orbit + * - Single-hand point for hover + * - Pinch to select + */ + +import { useEffect, useRef, useState, useCallback } from 'react' +import { Hands, Results, NormalizedLandmarkList } from '@mediapipe/hands' +import { Camera } from '@mediapipe/camera_utils' + +// Landmark indices +const WRIST = 0 +const THUMB_TIP = 4 +const INDEX_TIP = 8 +const MIDDLE_TIP = 12 +const RING_TIP = 16 +const PINKY_TIP = 20 +const INDEX_MCP = 5 // Base of index finger + +export interface HandLandmarks { + landmarks: NormalizedLandmarkList + worldLandmarks: NormalizedLandmarkList + handedness: 'Left' | 'Right' +} + +export interface GestureState { + // Are we tracking hands? + isTracking: boolean + handsDetected: number + + // Raw hand data + leftHand: HandLandmarks | null + rightHand: HandLandmarks | null + + // Computed gestures + twoHandDistance: number // Distance between wrists (0-1 normalized) + twoHandRotation: number // Angle in radians + twoHandCenter: { x: number; y: number } // Center point between hands + + // Single hand gestures + pointingHand: 'left' | 'right' | null + pointDirection: { x: number; y: number } | null // Normalized screen coords + pinchStrength: number // 0-1, how pinched is the pointing hand + grabStrength: number // 0-1, how closed is the fist + + // Derived control signals + zoomDelta: number // Positive = zoom in, negative = zoom out + rotateDelta: number // Rotation change since last frame + panDelta: { x: number; y: number } // Pan movement +} + +interface UseHandGesturesOptions { + enabled?: boolean + smoothing?: number // 0-1, higher = smoother but laggier + onGestureChange?: (state: GestureState) => void +} + +const DEFAULT_STATE: GestureState = { + isTracking: false, + handsDetected: 0, + leftHand: null, + rightHand: null, + twoHandDistance: 0.5, + twoHandRotation: 0, + twoHandCenter: { x: 0.5, y: 0.5 }, + pointingHand: null, + pointDirection: null, + pinchStrength: 0, + grabStrength: 0, + zoomDelta: 0, + rotateDelta: 0, + panDelta: { x: 0, y: 0 }, +} + +// Utility functions +function distance(a: { x: number; y: number; z?: number }, b: { x: number; y: number; z?: number }): number { + const dx = a.x - b.x + const dy = a.y - b.y + const dz = (a.z || 0) - (b.z || 0) + return Math.sqrt(dx * dx + dy * dy + dz * dz) +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +function lerpPoint(a: { x: number; y: number }, b: { x: number; y: number }, t: number): { x: number; y: number } { + return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) } +} + +function normalizeAngle(angle: number): number { + while (angle > Math.PI) angle -= 2 * Math.PI + while (angle < -Math.PI) angle += 2 * Math.PI + return angle +} + +// Calculate pinch strength (0 = open, 1 = pinched) +function calculatePinchStrength(landmarks: NormalizedLandmarkList): number { + const thumbTip = landmarks[THUMB_TIP] + const indexTip = landmarks[INDEX_TIP] + const dist = distance(thumbTip, indexTip) + // Typical range: 0.02 (pinched) to 0.15 (open) + return 1 - Math.min(1, Math.max(0, (dist - 0.02) / 0.13)) +} + +// Calculate grab strength (0 = open hand, 1 = fist) +function calculateGrabStrength(landmarks: NormalizedLandmarkList): number { + const wrist = landmarks[WRIST] + const fingertips = [THUMB_TIP, INDEX_TIP, MIDDLE_TIP, RING_TIP, PINKY_TIP].map(i => landmarks[i]) + const avgDist = fingertips.reduce((sum, tip) => sum + distance(tip, wrist), 0) / 5 + // Typical range: 0.08 (fist) to 0.25 (open) + return 1 - Math.min(1, Math.max(0, (avgDist - 0.08) / 0.17)) +} + +// Check if hand is pointing (index extended, others curled) +function isPointing(landmarks: NormalizedLandmarkList): boolean { + const indexExtended = landmarks[INDEX_TIP].y < landmarks[INDEX_MCP].y - 0.05 + const middleCurled = landmarks[MIDDLE_TIP].y > landmarks[12].y // MIDDLE_MCP + const ringCurled = landmarks[RING_TIP].y > landmarks[16].y // RING_MCP + const pinkyCurled = landmarks[PINKY_TIP].y > landmarks[20].y // PINKY_MCP + return indexExtended && (middleCurled || ringCurled || pinkyCurled) +} + +// Get pointing direction from index finger +function getPointDirection(landmarks: NormalizedLandmarkList): { x: number; y: number } { + const indexTip = landmarks[INDEX_TIP] + // Invert Y because screen coords are flipped + return { x: indexTip.x, y: 1 - indexTip.y } +} + +export function useHandGestures(options: UseHandGesturesOptions = {}) { + const { enabled = true, smoothing = 0.3, onGestureChange } = options + + const [gestureState, setGestureState] = useState(DEFAULT_STATE) + const videoRef = useRef(null) + const handsRef = useRef(null) + const cameraRef = useRef(null) + const prevStateRef = useRef(DEFAULT_STATE) + const isInitializedRef = useRef(false) + + // Process MediaPipe results + const onResults = useCallback((results: Results) => { + const prev = prevStateRef.current + const newState: GestureState = { ...DEFAULT_STATE } + + newState.isTracking = true + newState.handsDetected = results.multiHandLandmarks?.length || 0 + + if (results.multiHandLandmarks && results.multiHandedness) { + // Sort hands into left/right + for (let i = 0; i < results.multiHandLandmarks.length; i++) { + const landmarks = results.multiHandLandmarks[i] + const worldLandmarks = results.multiHandWorldLandmarks?.[i] || landmarks + const handedness = results.multiHandedness[i].label as 'Left' | 'Right' + + const handData: HandLandmarks = { + landmarks, + worldLandmarks, + // MediaPipe returns mirrored handedness, so flip it + handedness: handedness === 'Left' ? 'Right' : 'Left', + } + + if (handData.handedness === 'Left') { + newState.leftHand = handData + } else { + newState.rightHand = handData + } + } + + // Two-hand gestures + if (newState.leftHand && newState.rightHand) { + const leftWrist = newState.leftHand.landmarks[WRIST] + const rightWrist = newState.rightHand.landmarks[WRIST] + + // Distance between hands (normalized 0-1) + const rawDistance = distance(leftWrist, rightWrist) + newState.twoHandDistance = lerp(prev.twoHandDistance, rawDistance, 1 - smoothing) + + // Rotation angle + const rawRotation = Math.atan2( + rightWrist.y - leftWrist.y, + rightWrist.x - leftWrist.x + ) + newState.twoHandRotation = lerp(prev.twoHandRotation, rawRotation, 1 - smoothing) + + // Center point + const rawCenter = { + x: (leftWrist.x + rightWrist.x) / 2, + y: (leftWrist.y + rightWrist.y) / 2, + } + newState.twoHandCenter = lerpPoint(prev.twoHandCenter, rawCenter, 1 - smoothing) + + // Zoom delta (positive = spread apart = zoom in) + newState.zoomDelta = (newState.twoHandDistance - prev.twoHandDistance) * 5 + + // Rotation delta + newState.rotateDelta = normalizeAngle(newState.twoHandRotation - prev.twoHandRotation) + + // Pan delta + newState.panDelta = { + x: (newState.twoHandCenter.x - prev.twoHandCenter.x) * 2, + y: (newState.twoHandCenter.y - prev.twoHandCenter.y) * 2, + } + } + + // Single-hand gestures (prefer right hand for pointing) + // Only do single-hand gestures if we don't have both hands + if (newState.handsDetected === 1) { + const pointingHandData = newState.rightHand || newState.leftHand + if (pointingHandData) { + const landmarks = pointingHandData.landmarks + + if (isPointing(landmarks)) { + newState.pointingHand = pointingHandData.handedness === 'Right' ? 'right' : 'left' + newState.pointDirection = getPointDirection(landmarks) + } + + newState.pinchStrength = lerp( + prev.pinchStrength, + calculatePinchStrength(landmarks), + 1 - smoothing + ) + newState.grabStrength = lerp( + prev.grabStrength, + calculateGrabStrength(landmarks), + 1 - smoothing + ) + } + } + } + + prevStateRef.current = newState + setGestureState(newState) + onGestureChange?.(newState) + }, [smoothing, onGestureChange]) + + // Initialize MediaPipe + useEffect(() => { + if (!enabled || isInitializedRef.current) return + + const initializeHands = async () => { + // Create video element for camera + const video = document.createElement('video') + video.setAttribute('playsinline', '') + video.style.display = 'none' + document.body.appendChild(video) + videoRef.current = video + + // Initialize MediaPipe Hands + const hands = new Hands({ + locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`, + }) + + hands.setOptions({ + maxNumHands: 2, + modelComplexity: 1, + minDetectionConfidence: 0.7, + minTrackingConfidence: 0.5, + }) + + hands.onResults(onResults) + handsRef.current = hands + + // Initialize camera + const camera = new Camera(video, { + onFrame: async () => { + if (handsRef.current && videoRef.current) { + await handsRef.current.send({ image: videoRef.current }) + } + }, + width: 640, + height: 480, + }) + + cameraRef.current = camera + await camera.start() + isInitializedRef.current = true + } + + initializeHands().catch(console.error) + + return () => { + cameraRef.current?.stop() + handsRef.current?.close() + if (videoRef.current) { + videoRef.current.remove() + } + isInitializedRef.current = false + } + }, [enabled, onResults]) + + // Cleanup on unmount + useEffect(() => { + return () => { + cameraRef.current?.stop() + handsRef.current?.close() + if (videoRef.current) { + videoRef.current.remove() + } + } + }, []) + + return { + gestureState, + isEnabled: enabled && isInitializedRef.current, + // Expose video ref for potential overlay rendering + videoElement: videoRef.current, + } +} + +export default useHandGestures From 66d90d80b4a6f2b38b2242547e3846a9c033ec9e Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 05:11:25 +0100 Subject: [PATCH 07/47] feat(graph-viewer): add gesture debug overlay and pinch ray laser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GestureDebugOverlay component showing all hand tracking data: - FPS counter, tracking status, hands detected - Two-hand gesture values (distance, rotation, zoom/rotate/pan deltas) - Single-hand gestures (pinch strength, grab strength with progress bars) - Pinch ray data (origin, direction, strength) for both hands - Key landmark positions for each detected hand - Implement Meta Quest-style pinch ray (laser pointer): - Ray origin at midpoint between thumb and index tips - Direction from wrist toward pinch point - Visual intensity based on pinch strength - Glow sphere and ring at origin when pinch active - Dashed line when not fully pinched, solid when engaged - Integrate debug overlay into App with toggle button: - Debug button appears when gestures are enabled - Real-time gesture state updates via callback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/graph-viewer/src/App.tsx | 75 +++++ .../src/components/GestureDebugOverlay.tsx | 275 ++++++++++++++++++ .../src/components/GraphCanvas.tsx | 3 + .../src/components/HandSkeletonOverlay.tsx | 149 +++++++--- .../graph-viewer/src/hooks/useHandGestures.ts | 66 +++++ 5 files changed, 520 insertions(+), 48 deletions(-) create mode 100644 packages/graph-viewer/src/components/GestureDebugOverlay.tsx diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 628c058..ae052df 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -8,7 +8,30 @@ import { SearchBar } from './components/SearchBar' import { FilterPanel } from './components/FilterPanel' import { TokenPrompt } from './components/TokenPrompt' import { StatsBar } from './components/StatsBar' +import { GestureDebugOverlay } from './components/GestureDebugOverlay' import type { GraphNode, FilterState } from './lib/types' +import type { GestureState } from './hooks/useHandGestures' + +// Default gesture state for when not tracking +const DEFAULT_GESTURE_STATE: GestureState = { + isTracking: false, + handsDetected: 0, + leftHand: null, + rightHand: null, + twoHandDistance: 0.5, + twoHandRotation: 0, + twoHandCenter: { x: 0.5, y: 0.5 }, + pointingHand: null, + pointDirection: null, + pinchStrength: 0, + grabStrength: 0, + leftPinchRay: null, + rightPinchRay: null, + activePinchRay: null, + zoomDelta: 0, + rotateDelta: 0, + panDelta: { x: 0, y: 0 }, +} // Hand icon SVG component function HandIcon({ className }: { className?: string }) { @@ -22,18 +45,43 @@ function HandIcon({ className }: { className?: string }) { ) } +// Bug/Debug icon SVG component +function BugIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ) +} + export default function App() { const { setToken, isAuthenticated } = useAuth() const [selectedNode, setSelectedNode] = useState(null) const [hoveredNode, setHoveredNode] = useState(null) const [searchTerm, setSearchTerm] = useState('') const [gestureControlEnabled, setGestureControlEnabled] = useState(false) + const [debugOverlayVisible, setDebugOverlayVisible] = useState(false) + const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) const [filters, setFilters] = useState({ types: [], minImportance: 0, maxNodes: 500, }) + const handleGestureStateChange = useCallback((state: GestureState) => { + setGestureState(state) + }, []) + const { data, isLoading, error, refetch } = useGraphSnapshot({ limit: filters.maxNodes, minImportance: filters.minImportance, @@ -105,6 +153,26 @@ export default function App() { {gestureControlEnabled ? 'Gestures ON' : 'Gestures'} + + {/* Debug Overlay Toggle (only show when gestures enabled) */} + {gestureControlEnabled && ( + + )} {/* Main Content */} @@ -145,6 +213,13 @@ export default function App() { onNodeSelect={handleNodeSelect} onNodeHover={handleNodeHover} gestureControlEnabled={gestureControlEnabled} + onGestureStateChange={handleGestureStateChange} + /> + + {/* Gesture Debug Overlay */} +
diff --git a/packages/graph-viewer/src/components/GestureDebugOverlay.tsx b/packages/graph-viewer/src/components/GestureDebugOverlay.tsx new file mode 100644 index 0000000..2f7b87a --- /dev/null +++ b/packages/graph-viewer/src/components/GestureDebugOverlay.tsx @@ -0,0 +1,275 @@ +/** + * Gesture Debug Overlay + * + * Shows all hand tracking data in real-time for debugging: + * - Raw landmark positions + * - Computed gesture values + * - Hand detection confidence + * - FPS counter + */ + +import { useEffect, useRef, useState } from 'react' +import type { GestureState } from '../hooks/useHandGestures' + +interface GestureDebugOverlayProps { + gestureState: GestureState + visible: boolean +} + +export function GestureDebugOverlay({ gestureState, visible }: GestureDebugOverlayProps) { + const [fps, setFps] = useState(0) + const frameCountRef = useRef(0) + const lastTimeRef = useRef(performance.now()) + + // FPS counter + useEffect(() => { + frameCountRef.current++ + const now = performance.now() + if (now - lastTimeRef.current >= 1000) { + setFps(frameCountRef.current) + frameCountRef.current = 0 + lastTimeRef.current = now + } + }, [gestureState]) + + if (!visible) return null + + const { + isTracking, + handsDetected, + leftHand, + rightHand, + twoHandDistance, + twoHandRotation, + twoHandCenter, + pointingHand, + pointDirection, + pinchStrength, + grabStrength, + leftPinchRay, + rightPinchRay, + activePinchRay, + zoomDelta, + rotateDelta, + panDelta, + } = gestureState + + // Format number for display + const fmt = (n: number, decimals = 3) => n.toFixed(decimals) + + // Get landmark name + const landmarkName = (i: number) => { + const names = [ + 'WRIST', 'THUMB_CMC', 'THUMB_MCP', 'THUMB_IP', 'THUMB_TIP', + 'INDEX_MCP', 'INDEX_PIP', 'INDEX_DIP', 'INDEX_TIP', + 'MIDDLE_MCP', 'MIDDLE_PIP', 'MIDDLE_DIP', 'MIDDLE_TIP', + 'RING_MCP', 'RING_PIP', 'RING_DIP', 'RING_TIP', + 'PINKY_MCP', 'PINKY_PIP', 'PINKY_DIP', 'PINKY_TIP', + ] + return names[i] || `L${i}` + } + + return ( +
+ {/* Header */} +
+ GESTURE DEBUG + = 25 ? 'bg-green-500/20' : fps >= 15 ? 'bg-yellow-500/20' : 'bg-red-500/20'}`}> + {fps} FPS + +
+ + {/* Tracking Status */} +
+
Status
+
+
Tracking: {isTracking ? 'YES' : 'NO'}
+
Hands: {handsDetected}
+
+
+ + {/* Two-Hand Gestures */} +
+
Two-Hand Gestures
+
+
+ Distance: + {fmt(twoHandDistance)} +
+
+
+
+
+ Rotation: + {fmt(twoHandRotation * 180 / Math.PI)}° +
+
+ Center: + ({fmt(twoHandCenter.x, 2)}, {fmt(twoHandCenter.y, 2)}) +
+
+ Zoom Δ: + 0 ? 'text-green-400' : zoomDelta < 0 ? 'text-red-400' : 'text-gray-400'}> + {zoomDelta > 0 ? '+' : ''}{fmt(zoomDelta, 4)} + +
+
+ Rotate Δ: + {fmt(rotateDelta * 180 / Math.PI, 2)}° +
+
+ Pan Δ: + ({fmt(panDelta.x, 3)}, {fmt(panDelta.y, 3)}) +
+
+
+ + {/* Single-Hand Gestures */} +
+
Single-Hand Gestures
+
+
+ Pointing: + + {pointingHand || 'NONE'} + +
+ {pointDirection && ( +
+ Point Dir: + ({fmt(pointDirection.x, 2)}, {fmt(pointDirection.y, 2)}) +
+ )} +
+ Pinch: + {fmt(pinchStrength, 2)} +
+
0.7 ? 'bg-orange-500' : 'bg-orange-400/50'}`} + style={{ width: `${pinchStrength * 100}%` }} + /> +
+
+
+ Grab: + {fmt(grabStrength, 2)} +
+
0.7 ? 'bg-red-500' : 'bg-red-400/50'}`} + style={{ width: `${grabStrength * 100}%` }} + /> +
+
+
+
+ + {/* Pinch Ray (Laser Pointer) */} +
+
Pinch Ray (Laser)
+
+
+ Active: + + {activePinchRay?.isValid ? 'YES' : 'NO'} + +
+ {leftPinchRay && ( + <> +
+ L Pinch: + + {fmt(leftPinchRay.strength, 2)} + +
+
+
+
+
+ L Origin: + + ({fmt(leftPinchRay.origin.x, 2)}, {fmt(leftPinchRay.origin.y, 2)}, {fmt(leftPinchRay.origin.z, 2)}) + +
+ + )} + {rightPinchRay && ( + <> +
+ R Pinch: + + {fmt(rightPinchRay.strength, 2)} + +
+
+
+
+
+ R Origin: + + ({fmt(rightPinchRay.origin.x, 2)}, {fmt(rightPinchRay.origin.y, 2)}, {fmt(rightPinchRay.origin.z, 2)}) + +
+ + )} +
+
+ + {/* Left Hand Landmarks */} + {leftHand && ( +
+
Left Hand (21 landmarks)
+
+ {[0, 4, 8, 12, 16, 20].map((i) => { + const lm = leftHand.landmarks[i] + return ( +
+ {landmarkName(i)}: + + ({fmt(lm.x, 2)}, {fmt(lm.y, 2)}, {fmt(lm.z || 0, 2)}) + +
+ ) + })} +
+
+ )} + + {/* Right Hand Landmarks */} + {rightHand && ( +
+
Right Hand (21 landmarks)
+
+ {[0, 4, 8, 12, 16, 20].map((i) => { + const lm = rightHand.landmarks[i] + return ( +
+ {landmarkName(i)}: + + ({fmt(lm.x, 2)}, {fmt(lm.y, 2)}, {fmt(lm.z || 0, 2)}) + +
+ ) + })} +
+
+ )} + + {/* Instructions */} +
+
TIP: Landmarks x,y are 0-1 normalized
+
TIP: z is depth relative to wrist
+
+
+ ) +} + +export default GestureDebugOverlay diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 2ecd665..c050685 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -18,6 +18,7 @@ interface GraphCanvasProps { onNodeSelect: (node: GraphNode | null) => void onNodeHover: (node: GraphNode | null) => void gestureControlEnabled?: boolean + onGestureStateChange?: (state: GestureState) => void } export function GraphCanvas({ @@ -29,10 +30,12 @@ export function GraphCanvas({ onNodeSelect, onNodeHover, gestureControlEnabled = false, + onGestureStateChange, }: GraphCanvasProps) { // Hand gesture tracking const { gestureState, isEnabled: gesturesActive } = useHandGestures({ enabled: gestureControlEnabled, + onGestureChange: onGestureStateChange, }) return ( diff --git a/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx b/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx index 9ea1e45..7961f10 100644 --- a/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx +++ b/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx @@ -7,7 +7,7 @@ import { useMemo } from 'react' import { Line } from '@react-three/drei' -import type { HandLandmarks, GestureState } from '../hooks/useHandGestures' +import type { HandLandmarks, GestureState, PinchRay } from '../hooks/useHandGestures' // Hand skeleton connections (pairs of landmark indices) const HAND_CONNECTIONS = [ @@ -93,59 +93,112 @@ function HandSkeleton({ hand, color, opacity = 0.8, scale = 10 }: HandSkeletonPr ) } -interface GestureIndicatorProps { - gestureState: GestureState +// Convert pinch ray origin (normalized 0-1 coords) to Three.js world coords +function pinchRayToWorld( + ray: PinchRay, + scale: number = 10 +): { origin: [number, number, number]; end: [number, number, number] } { + // Origin in 3D space + const origin: [number, number, number] = [ + (ray.origin.x - 0.5) * scale, + -(ray.origin.y - 0.5) * scale, // Flip Y + -ray.origin.z * scale * 2, + ] + + // Ray extends in the direction, scaled by ray length + const rayLength = 100 // How far the laser extends + const end: [number, number, number] = [ + origin[0] + ray.direction.x * rayLength, + origin[1] - ray.direction.y * rayLength, // Flip Y for direction too + origin[2] - ray.direction.z * rayLength, + ] + + return { origin, end } } -function GestureIndicator({ gestureState }: GestureIndicatorProps) { - const { handsDetected, zoomDelta, pointDirection } = gestureState - - // Pointing ray - if (pointDirection && handsDetected === 1) { - const rayStart: [number, number, number] = [ - (pointDirection.x - 0.5) * 10, - (pointDirection.y - 0.5) * 10, - 0, - ] - const rayEnd: [number, number, number] = [ - (pointDirection.x - 0.5) * 10, - (pointDirection.y - 0.5) * 10, - -50, // Ray extends into the scene - ] - - return ( - - pinchRayToWorld(ray, scale), [ray, scale]) + + // Calculate visual properties based on pinch strength + const lineWidth = 1 + ray.strength * 3 // Thicker when pinching harder + const opacity = 0.3 + ray.strength * 0.5 // More visible when pinching + + // Glow sphere size at origin + const sphereSize = 0.1 + ray.strength * 0.15 + + return ( + + {/* Main laser beam */} + + + {/* Origin glow sphere (where thumb meets index) */} + + + - {/* Pointer dot */} - - - - - - ) - } - - // Two-hand zoom indicator - if (handsDetected === 2 && Math.abs(zoomDelta) > 0.01) { - const zoomColor = zoomDelta > 0 ? '#4ecdc4' : '#ff6b6b' - return ( - - - - ) - } - return null + {/* Secondary glow ring when pinch is active */} + {ray.isValid && ( + + + + + )} + + ) +} + +interface GestureIndicatorProps { + gestureState: GestureState +} + +function GestureIndicator({ gestureState }: GestureIndicatorProps) { + const { handsDetected, zoomDelta, leftPinchRay, rightPinchRay } = gestureState + + return ( + + {/* Left hand pinch ray - cyan */} + {leftPinchRay && leftPinchRay.strength > 0.3 && ( + + )} + + {/* Right hand pinch ray - magenta */} + {rightPinchRay && rightPinchRay.strength > 0.3 && ( + + )} + + {/* Two-hand zoom indicator */} + {handsDetected === 2 && Math.abs(zoomDelta) > 0.01 && ( + + + 0 ? '#4ecdc4' : '#ff6b6b'} + transparent + opacity={0.6} + /> + + )} + + ) } interface HandSkeletonOverlayProps { diff --git a/packages/graph-viewer/src/hooks/useHandGestures.ts b/packages/graph-viewer/src/hooks/useHandGestures.ts index c29b2e4..93dacc2 100644 --- a/packages/graph-viewer/src/hooks/useHandGestures.ts +++ b/packages/graph-viewer/src/hooks/useHandGestures.ts @@ -28,6 +28,17 @@ export interface HandLandmarks { handedness: 'Left' | 'Right' } +export interface PinchRay { + // Origin point (midpoint between thumb and index tips) + origin: { x: number; y: number; z: number } + // Direction vector (from wrist toward pinch point) + direction: { x: number; y: number; z: number } + // Is the ray valid for interaction? + isValid: boolean + // Current pinch strength (0-1) + strength: number +} + export interface GestureState { // Are we tracking hands? isTracking: boolean @@ -48,6 +59,11 @@ export interface GestureState { pinchStrength: number // 0-1, how pinched is the pointing hand grabStrength: number // 0-1, how closed is the fist + // Pinch ray for laser pointer (Meta Quest style) + leftPinchRay: PinchRay | null + rightPinchRay: PinchRay | null + activePinchRay: PinchRay | null // The one currently being used for interaction + // Derived control signals zoomDelta: number // Positive = zoom in, negative = zoom out rotateDelta: number // Rotation change since last frame @@ -72,6 +88,9 @@ const DEFAULT_STATE: GestureState = { pointDirection: null, pinchStrength: 0, grabStrength: 0, + leftPinchRay: null, + rightPinchRay: null, + activePinchRay: null, zoomDelta: 0, rotateDelta: 0, panDelta: { x: 0, y: 0 }, @@ -133,6 +152,42 @@ function getPointDirection(landmarks: NormalizedLandmarkList): { x: number; y: n return { x: indexTip.x, y: 1 - indexTip.y } } +// Calculate pinch ray (Meta Quest style - ray from pinch midpoint) +function calculatePinchRay(landmarks: NormalizedLandmarkList): PinchRay { + const thumbTip = landmarks[THUMB_TIP] + const indexTip = landmarks[INDEX_TIP] + const wrist = landmarks[WRIST] + + // Origin is the midpoint between thumb and index tips (Meta Quest PointerPose) + const origin = { + x: (thumbTip.x + indexTip.x) / 2, + y: (thumbTip.y + indexTip.y) / 2, + z: ((thumbTip.z || 0) + (indexTip.z || 0)) / 2, + } + + // Direction vector from wrist toward the pinch point + const rawDir = { + x: origin.x - wrist.x, + y: origin.y - wrist.y, + z: (origin.z || 0) - (wrist.z || 0), + } + + // Normalize the direction vector + const length = Math.sqrt(rawDir.x * rawDir.x + rawDir.y * rawDir.y + rawDir.z * rawDir.z) + const direction = length > 0 + ? { x: rawDir.x / length, y: rawDir.y / length, z: rawDir.z / length } + : { x: 0, y: 0, z: -1 } // Default pointing into screen + + // Calculate pinch strength for this hand + const pinchDist = distance(thumbTip, indexTip) + const strength = 1 - Math.min(1, Math.max(0, (pinchDist - 0.02) / 0.13)) + + // Ray is valid when pinch strength is above threshold + const isValid = strength > 0.5 + + return { origin, direction, isValid, strength } +} + export function useHandGestures(options: UseHandGesturesOptions = {}) { const { enabled = true, smoothing = 0.3, onGestureChange } = options @@ -167,11 +222,22 @@ export function useHandGestures(options: UseHandGesturesOptions = {}) { if (handData.handedness === 'Left') { newState.leftHand = handData + // Compute left pinch ray + newState.leftPinchRay = calculatePinchRay(landmarks) } else { newState.rightHand = handData + // Compute right pinch ray + newState.rightPinchRay = calculatePinchRay(landmarks) } } + // Determine active pinch ray (prefer right hand, use strongest pinch) + if (newState.rightPinchRay && newState.rightPinchRay.isValid) { + newState.activePinchRay = newState.rightPinchRay + } else if (newState.leftPinchRay && newState.leftPinchRay.isValid) { + newState.activePinchRay = newState.leftPinchRay + } + // Two-hand gestures if (newState.leftHand && newState.rightHand) { const leftWrist = newState.leftHand.landmarks[WRIST] From b549ca93386d982ee90fbde7ae7d3ade17083138 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 05:46:45 +0100 Subject: [PATCH 08/47] feat(graph-viewer): add 2D hand overlay for life-size hand visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create Hand2DOverlay component using SVG for crisp rendering - Hand appears as screen overlay (not inside 3D scene) - Scale based on hand depth: closer to camera = larger hand - Include pinch-to-laser with glow effects - Mirror X axis to match natural hand movement - Disable 3D HandSkeletonOverlay in favor of 2D version The hand now appears life-size against the screen, matching the user's actual hand position relative to the camera. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/graph-viewer/src/App.tsx | 8 + .../src/components/GraphCanvas.tsx | 8 +- .../src/components/Hand2DOverlay.tsx | 234 ++++++++++++++++++ 3 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 packages/graph-viewer/src/components/Hand2DOverlay.tsx diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index ae052df..8a9c836 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -9,6 +9,7 @@ import { FilterPanel } from './components/FilterPanel' import { TokenPrompt } from './components/TokenPrompt' import { StatsBar } from './components/StatsBar' import { GestureDebugOverlay } from './components/GestureDebugOverlay' +import { Hand2DOverlay } from './components/Hand2DOverlay' import type { GraphNode, FilterState } from './lib/types' import type { GestureState } from './hooks/useHandGestures' @@ -216,6 +217,13 @@ export default function App() { onGestureStateChange={handleGestureStateChange} /> + {/* 2D Hand Overlay (on top of canvas, life-size) */} + + {/* Gesture Debug Overlay */} - {/* Hand skeleton overlay */} - {gestureControlEnabled && ( + {/* 3D Hand skeleton overlay - disabled, using 2D overlay instead */} + {/* {gestureControlEnabled && ( - )} + )} */} {/* Graph content */} diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx new file mode 100644 index 0000000..1223369 --- /dev/null +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -0,0 +1,234 @@ +/** + * Hand 2D Overlay + * + * Renders the hand as a 2D overlay on top of the canvas (not inside the 3D scene). + * The hand appears life-size relative to the screen - closer to camera = larger. + * Uses SVG for crisp rendering at any size. + */ + +import type { GestureState, PinchRay } from '../hooks/useHandGestures' + +// Hand skeleton connections (pairs of landmark indices) +const HAND_CONNECTIONS = [ + // Thumb + [0, 1], [1, 2], [2, 3], [3, 4], + // Index finger + [0, 5], [5, 6], [6, 7], [7, 8], + // Middle finger + [0, 9], [9, 10], [10, 11], [11, 12], + // Ring finger + [0, 13], [13, 14], [14, 15], [15, 16], + // Pinky + [0, 17], [17, 18], [18, 19], [19, 20], + // Palm + [5, 9], [9, 13], [13, 17], [0, 17], +] + +// Fingertip indices for larger dots +const FINGERTIPS = [4, 8, 12, 16, 20] + +interface Hand2DOverlayProps { + gestureState: GestureState + enabled?: boolean + showLaser?: boolean +} + +export function Hand2DOverlay({ gestureState, enabled = true, showLaser = true }: Hand2DOverlayProps) { + if (!enabled || !gestureState.isTracking) return null + + // Get the dominant hand (prefer right, fallback to left) + const hand = gestureState.rightHand || gestureState.leftHand + const pinchRay = gestureState.rightPinchRay || gestureState.leftPinchRay + const isRightHand = !!gestureState.rightHand + const color = isRightHand ? '#f72585' : '#4ecdc4' + + if (!hand) return null + + return ( +
+ + {/* Hand skeleton */} + + + {/* Laser beam from pinch point */} + {showLaser && pinchRay && pinchRay.strength > 0.3 && ( + + )} + +
+ ) +} + +interface HandSkeletonProps { + landmarks: { x: number; y: number; z?: number }[] + color: string +} + +function HandSkeleton({ landmarks, color }: HandSkeletonProps) { + // Calculate scale based on hand depth (z of wrist) + // Closer to camera = larger scale + const wristZ = landmarks[0].z || 0 + // Z typically ranges from -0.1 (close) to 0.1 (far) + // Map to scale: close = 1.5x, far = 0.5x + const depthScale = 1 - wristZ * 3 + const clampedScale = Math.max(0.5, Math.min(2, depthScale)) + + // Base stroke width that scales with depth + const baseStroke = 0.3 * clampedScale + const jointRadius = 0.4 * clampedScale + const fingertipRadius = 0.6 * clampedScale + + // Convert normalized coords (0-1) to viewBox coords (0-100) + // Mirror X because webcam is mirrored + const toSvg = (lm: { x: number; y: number }) => ({ + x: (1 - lm.x) * 100, + y: lm.y * 100, + }) + + const points = landmarks.map(toSvg) + + return ( + + {/* Connection lines */} + {HAND_CONNECTIONS.map(([i, j], idx) => { + const p1 = points[i] + const p2 = points[j] + return ( + + ) + })} + + {/* Joint dots */} + {points.map((p, idx) => { + const isFingertip = FINGERTIPS.includes(idx) + const radius = isFingertip ? fingertipRadius : jointRadius + return ( + + ) + })} + + {/* Glow effect for fingertips */} + {FINGERTIPS.map((idx) => { + const p = points[idx] + return ( + + ) + })} + + ) +} + +interface LaserBeamProps { + ray: PinchRay + color: string +} + +function LaserBeam({ ray, color }: LaserBeamProps) { + // Origin in screen coords (mirror X) + const originX = (1 - ray.origin.x) * 100 + const originY = ray.origin.y * 100 + + // Calculate end point - laser extends in direction + // The direction needs to be projected into screen space + // For now, extend toward center-bottom of screen (into the graph) + const laserLength = 150 // Extend beyond viewport + const endX = originX + (1 - ray.direction.x) * laserLength + const endY = originY + ray.direction.y * laserLength + + // Visual properties based on pinch strength + const strokeWidth = 0.2 + ray.strength * 0.5 + const opacity = 0.3 + ray.strength * 0.5 + const glowRadius = 0.8 + ray.strength * 0.8 + + return ( + + {/* Laser glow (wider, more transparent) */} + + + {/* Main laser beam */} + + + {/* Origin glow sphere */} + + + + {/* Pulsing ring when pinch is active */} + {ray.isValid && ( + + )} + + ) +} + +export default Hand2DOverlay From bfa8d9ace512be44a49d3fd71a910070a7b69d53 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 05:50:53 +0100 Subject: [PATCH 09/47] fix(graph-viewer): un-mirror hand and invert depth direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove X-axis mirroring so hand points same direction as user's - Invert depth scaling: closer to camera = smaller (going into screen) - Laser now points toward center of graph (the nexus) - Hand shrinks as it "reaches into" the screen toward the memory graph 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/Hand2DOverlay.tsx | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 1223369..9bc4ae4 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -73,12 +73,12 @@ interface HandSkeletonProps { function HandSkeleton({ landmarks, color }: HandSkeletonProps) { // Calculate scale based on hand depth (z of wrist) - // Closer to camera = larger scale + // Closer to camera = SMALLER (further into scene), far from camera = LARGER (closer to viewer) const wristZ = landmarks[0].z || 0 - // Z typically ranges from -0.1 (close) to 0.1 (far) - // Map to scale: close = 1.5x, far = 0.5x - const depthScale = 1 - wristZ * 3 - const clampedScale = Math.max(0.5, Math.min(2, depthScale)) + // Z typically ranges from -0.1 (close to camera) to 0.1 (far from camera) + // Invert: close to camera = smaller (going into screen), far = larger + const depthScale = 1 + wristZ * 5 + const clampedScale = Math.max(0.3, Math.min(1.5, depthScale)) // Base stroke width that scales with depth const baseStroke = 0.3 * clampedScale @@ -86,9 +86,9 @@ function HandSkeleton({ landmarks, color }: HandSkeletonProps) { const fingertipRadius = 0.6 * clampedScale // Convert normalized coords (0-1) to viewBox coords (0-100) - // Mirror X because webcam is mirrored + // NOT mirrored - hand points same direction as yours const toSvg = (lm: { x: number; y: number }) => ({ - x: (1 - lm.x) * 100, + x: lm.x * 100, y: lm.y * 100, }) @@ -155,16 +155,25 @@ interface LaserBeamProps { } function LaserBeam({ ray, color }: LaserBeamProps) { - // Origin in screen coords (mirror X) - const originX = (1 - ray.origin.x) * 100 + // Origin in screen coords (NOT mirrored) + const originX = ray.origin.x * 100 const originY = ray.origin.y * 100 - // Calculate end point - laser extends in direction - // The direction needs to be projected into screen space - // For now, extend toward center-bottom of screen (into the graph) + // Calculate end point - laser extends toward center of graph (into the nexus) + // Direction points from hand toward center of screen + const centerX = 50 + const centerY = 50 const laserLength = 150 // Extend beyond viewport - const endX = originX + (1 - ray.direction.x) * laserLength - const endY = originY + ray.direction.y * laserLength + + // Direction toward center + const toCenterX = centerX - originX + const toCenterY = centerY - originY + const dist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) + const normX = dist > 0 ? toCenterX / dist : 0 + const normY = dist > 0 ? toCenterY / dist : 0 + + const endX = originX + normX * laserLength + const endY = originY + normY * laserLength // Visual properties based on pinch strength const strokeWidth = 0.2 + ray.strength * 0.5 From 53a7b14a54f6a1819ec425839ae0561d932469f9 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 06:13:24 +0100 Subject: [PATCH 10/47] feat(graph-viewer): add hand smoothing, ghost effect, grip indicator, and two-hand support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add position interpolation with SMOOTHING_FACTOR for fluid hand tracking - Implement ghost effect with configurable fade when hand disappears - Track both left (cyan) and right (magenta) hands - Lasers always point toward center nexus - Grip indicator: laser turns white/bright when pinched - Connection line between hands when both gripping - Impact 'warm spot' at center where laser hits - SVG glow filters for visual effects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/Hand2DOverlay.tsx | 332 +++++++++++++++--- 1 file changed, 281 insertions(+), 51 deletions(-) diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 9bc4ae4..e6eebdd 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -1,11 +1,14 @@ /** * Hand 2D Overlay * - * Renders the hand as a 2D overlay on top of the canvas (not inside the 3D scene). - * The hand appears life-size relative to the screen - closer to camera = larger. - * Uses SVG for crisp rendering at any size. + * Renders hands as a 2D overlay on top of the canvas with: + * - Smoothing/interpolation (ghost effect when hand disappears) + * - Laser beams pointing toward the memory nexus center + * - Pinch grip indicator (lights up when gripped) + * - Support for two-hand manipulation */ +import { useState, useEffect, useRef } from 'react' import type { GestureState, PinchRay } from '../hooks/useHandGestures' // Hand skeleton connections (pairs of landmark indices) @@ -27,6 +30,18 @@ const HAND_CONNECTIONS = [ // Fingertip indices for larger dots const FINGERTIPS = [4, 8, 12, 16, 20] +// Smoothing configuration +const SMOOTHING_FACTOR = 0.15 // Lower = smoother but laggier +const GHOST_FADE_DURATION = 500 // ms to fade out ghost hand +const GHOST_PERSIST_DURATION = 300 // ms to keep ghost before fading + +interface SmoothedHand { + landmarks: { x: number; y: number; z: number }[] + lastSeen: number + isGhost: boolean + opacity: number +} + interface Hand2DOverlayProps { gestureState: GestureState enabled?: boolean @@ -34,15 +49,106 @@ interface Hand2DOverlayProps { } export function Hand2DOverlay({ gestureState, enabled = true, showLaser = true }: Hand2DOverlayProps) { - if (!enabled || !gestureState.isTracking) return null + // Track smoothed hand positions with ghost effect + const [leftSmoothed, setLeftSmoothed] = useState(null) + const [rightSmoothed, setRightSmoothed] = useState(null) + const animationRef = useRef() + + // Smoothing and ghost effect + useEffect(() => { + if (!enabled) return + + const now = Date.now() + + // Process left hand + if (gestureState.leftHand) { + setLeftSmoothed(prev => { + const newLandmarks = gestureState.leftHand!.landmarks.map((lm, i) => { + const prevLm = prev?.landmarks[i] + if (prevLm && !prev.isGhost) { + // Interpolate toward new position + return { + x: prevLm.x + (lm.x - prevLm.x) * SMOOTHING_FACTOR, + y: prevLm.y + (lm.y - prevLm.y) * SMOOTHING_FACTOR, + z: prevLm.z + ((lm.z || 0) - prevLm.z) * SMOOTHING_FACTOR, + } + } + return { x: lm.x, y: lm.y, z: lm.z || 0 } + }) + return { landmarks: newLandmarks, lastSeen: now, isGhost: false, opacity: 1 } + }) + } else if (leftSmoothed && !leftSmoothed.isGhost) { + // Hand disappeared - start ghost mode + setLeftSmoothed(prev => prev ? { ...prev, isGhost: true, lastSeen: now } : null) + } + + // Process right hand + if (gestureState.rightHand) { + setRightSmoothed(prev => { + const newLandmarks = gestureState.rightHand!.landmarks.map((lm, i) => { + const prevLm = prev?.landmarks[i] + if (prevLm && !prev.isGhost) { + return { + x: prevLm.x + (lm.x - prevLm.x) * SMOOTHING_FACTOR, + y: prevLm.y + (lm.y - prevLm.y) * SMOOTHING_FACTOR, + z: prevLm.z + ((lm.z || 0) - prevLm.z) * SMOOTHING_FACTOR, + } + } + return { x: lm.x, y: lm.y, z: lm.z || 0 } + }) + return { landmarks: newLandmarks, lastSeen: now, isGhost: false, opacity: 1 } + }) + } else if (rightSmoothed && !rightSmoothed.isGhost) { + setRightSmoothed(prev => prev ? { ...prev, isGhost: true, lastSeen: now } : null) + } + }, [gestureState, enabled]) + + // Ghost fade animation + useEffect(() => { + const animate = () => { + const now = Date.now() + + // Fade left ghost + if (leftSmoothed?.isGhost) { + const elapsed = now - leftSmoothed.lastSeen + if (elapsed > GHOST_PERSIST_DURATION) { + const fadeProgress = (elapsed - GHOST_PERSIST_DURATION) / GHOST_FADE_DURATION + if (fadeProgress >= 1) { + setLeftSmoothed(null) + } else { + setLeftSmoothed(prev => prev ? { ...prev, opacity: 1 - fadeProgress } : null) + } + } + } + + // Fade right ghost + if (rightSmoothed?.isGhost) { + const elapsed = now - rightSmoothed.lastSeen + if (elapsed > GHOST_PERSIST_DURATION) { + const fadeProgress = (elapsed - GHOST_PERSIST_DURATION) / GHOST_FADE_DURATION + if (fadeProgress >= 1) { + setRightSmoothed(null) + } else { + setRightSmoothed(prev => prev ? { ...prev, opacity: 1 - fadeProgress } : null) + } + } + } + + animationRef.current = requestAnimationFrame(animate) + } + + animationRef.current = requestAnimationFrame(animate) + return () => { + if (animationRef.current) cancelAnimationFrame(animationRef.current) + } + }, [leftSmoothed?.isGhost, rightSmoothed?.isGhost]) - // Get the dominant hand (prefer right, fallback to left) - const hand = gestureState.rightHand || gestureState.leftHand - const pinchRay = gestureState.rightPinchRay || gestureState.leftPinchRay - const isRightHand = !!gestureState.rightHand - const color = isRightHand ? '#f72585' : '#4ecdc4' + if (!enabled || !gestureState.isTracking) return null - if (!hand) return null + // Check if both hands are gripping (for two-hand manipulation) + const leftGripping = gestureState.leftPinchRay?.isValid + const rightGripping = gestureState.rightPinchRay?.isValid + const bothGripping = leftGripping && rightGripping return (
@@ -51,15 +157,94 @@ export function Hand2DOverlay({ gestureState, enabled = true, showLaser = true } viewBox="0 0 100 100" preserveAspectRatio="none" > - {/* Hand skeleton */} - + {/* Define glow filter for grip effect */} + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Left hand - cyan */} + {leftSmoothed && ( + + + + )} - {/* Laser beam from pinch point */} - {showLaser && pinchRay && pinchRay.strength > 0.3 && ( - + {/* Right hand - magenta */} + {rightSmoothed && ( + + + + )} + + {/* Connection line between hands when both gripping */} + {bothGripping && gestureState.leftPinchRay && gestureState.rightPinchRay && ( + + )} + + {/* Left laser */} + {showLaser && gestureState.leftPinchRay && gestureState.leftPinchRay.strength > 0.3 && ( + + )} + + {/* Right laser */} + {showLaser && gestureState.rightPinchRay && gestureState.rightPinchRay.strength > 0.3 && ( + + )} + + {/* Center nexus indicator when gripping */} + {(leftGripping || rightGripping) && ( + + + + )}
@@ -67,16 +252,14 @@ export function Hand2DOverlay({ gestureState, enabled = true, showLaser = true } } interface HandSkeletonProps { - landmarks: { x: number; y: number; z?: number }[] + landmarks: { x: number; y: number; z: number }[] color: string + isGhost?: boolean } -function HandSkeleton({ landmarks, color }: HandSkeletonProps) { +function HandSkeleton({ landmarks, color, isGhost = false }: HandSkeletonProps) { // Calculate scale based on hand depth (z of wrist) - // Closer to camera = SMALLER (further into scene), far from camera = LARGER (closer to viewer) const wristZ = landmarks[0].z || 0 - // Z typically ranges from -0.1 (close to camera) to 0.1 (far from camera) - // Invert: close to camera = smaller (going into screen), far = larger const depthScale = 1 + wristZ * 5 const clampedScale = Math.max(0.3, Math.min(1.5, depthScale)) @@ -85,8 +268,9 @@ function HandSkeleton({ landmarks, color }: HandSkeletonProps) { const jointRadius = 0.4 * clampedScale const fingertipRadius = 0.6 * clampedScale - // Convert normalized coords (0-1) to viewBox coords (0-100) - // NOT mirrored - hand points same direction as yours + // Ghost hands are more transparent and have a blur effect + const baseOpacity = isGhost ? 0.4 : 0.8 + const toSvg = (lm: { x: number; y: number }) => ({ x: lm.x * 100, y: lm.y * 100, @@ -110,7 +294,7 @@ function HandSkeleton({ landmarks, color }: HandSkeletonProps) { stroke={color} strokeWidth={baseStroke} strokeLinecap="round" - opacity={0.8} + opacity={baseOpacity} /> ) })} @@ -126,13 +310,13 @@ function HandSkeleton({ landmarks, color }: HandSkeletonProps) { cy={p.y} r={radius} fill={color} - opacity={isFingertip ? 1 : 0.7} + opacity={isFingertip ? baseOpacity + 0.2 : baseOpacity - 0.1} /> ) })} {/* Glow effect for fingertips */} - {FINGERTIPS.map((idx) => { + {!isGhost && FINGERTIPS.map((idx) => { const p = points[idx] return ( ) })} @@ -152,18 +336,17 @@ function HandSkeleton({ landmarks, color }: HandSkeletonProps) { interface LaserBeamProps { ray: PinchRay color: string + isGripped: boolean + otherRay?: PinchRay | null } -function LaserBeam({ ray, color }: LaserBeamProps) { - // Origin in screen coords (NOT mirrored) +function LaserBeam({ ray, color, isGripped }: LaserBeamProps) { const originX = ray.origin.x * 100 const originY = ray.origin.y * 100 - // Calculate end point - laser extends toward center of graph (into the nexus) - // Direction points from hand toward center of screen + // Laser always points toward center of screen (the nexus) const centerX = 50 const centerY = 50 - const laserLength = 150 // Extend beyond viewport // Direction toward center const toCenterX = centerX - originX @@ -172,16 +355,37 @@ function LaserBeam({ ray, color }: LaserBeamProps) { const normX = dist > 0 ? toCenterX / dist : 0 const normY = dist > 0 ? toCenterY / dist : 0 + // Laser extends to center, not beyond (it "hits" the nexus) + const laserLength = dist const endX = originX + normX * laserLength const endY = originY + normY * laserLength - // Visual properties based on pinch strength - const strokeWidth = 0.2 + ray.strength * 0.5 - const opacity = 0.3 + ray.strength * 0.5 - const glowRadius = 0.8 + ray.strength * 0.8 + // Visual properties - much more intense when gripped + const baseStrokeWidth = 0.2 + ray.strength * 0.3 + const strokeWidth = isGripped ? baseStrokeWidth * 2.5 : baseStrokeWidth + const baseOpacity = 0.3 + ray.strength * 0.4 + const opacity = isGripped ? Math.min(1, baseOpacity * 1.8) : baseOpacity + const glowRadius = isGripped ? 2 + ray.strength * 2 : 0.8 + ray.strength * 0.8 + + // Grip indicator color - brighter white-ish when gripped + const gripColor = isGripped ? '#ffffff' : color return ( - + + {/* Outer glow when gripped */} + {isGripped && ( + + )} + {/* Laser glow (wider, more transparent) */} @@ -200,39 +404,65 @@ function LaserBeam({ ray, color }: LaserBeamProps) { y1={originY} x2={endX} y2={endY} - stroke={color} + stroke={gripColor} strokeWidth={strokeWidth} strokeLinecap="round" opacity={opacity} - strokeDasharray={ray.isValid ? 'none' : '2 1'} /> {/* Origin glow sphere */} + + {/* Impact point at center - "warm spot" where laser hits the nexus */} + + {isGripped && ( + <> + + + + )} - {/* Pulsing ring when pinch is active */} - {ray.isValid && ( + {/* Pulsing ring at origin when gripped */} + {isGripped && ( )} From 9cc7a2f78886b215e04d553d541fe8cc09e6b5b9 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 06:15:33 +0100 Subject: [PATCH 11/47] feat(graph-viewer): add two-hand pinch manipulation for graph control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both hands are gripping (pinch valid), users can: - SPREAD hands apart = zoom in (move camera closer) - PINCH hands together = zoom out - ROTATE hands around each other = orbit camera - MOVE both hands together = pan view - PULL both hands toward camera = dolly in (pull graph closer) - PUSH both hands away = dolly out Falls back to wrist-based gestures when hands are detected but not gripping. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/GraphCanvas.tsx | 178 ++++++++++++++---- 1 file changed, 146 insertions(+), 32 deletions(-) diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index e55e11e..74e4fb1 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -118,44 +118,158 @@ function Scene({ setAutoRotate(false) }, []) + // Track previous pinch state for delta calculations + const prevPinchStateRef = useRef<{ + leftOrigin: { x: number; y: number; z: number } | null + rightOrigin: { x: number; y: number; z: number } | null + distance: number + rotation: number + center: { x: number; y: number } + }>({ + leftOrigin: null, + rightOrigin: null, + distance: 0, + rotation: 0, + center: { x: 0.5, y: 0.5 }, + }) + // Apply gesture controls to camera useEffect(() => { if (!gestureControlEnabled || !controlsRef.current) return - if (!gestureState.isTracking || gestureState.handsDetected < 2) return + if (!gestureState.isTracking) return const controls = controlsRef.current - - // Two-hand zoom: spread = zoom in, pinch = zoom out - if (Math.abs(gestureState.zoomDelta) > 0.001) { - // Dolly in/out based on hand spread - const zoomFactor = 1 - gestureState.zoomDelta * 0.5 - controls.object.position.multiplyScalar(zoomFactor) - setAutoRotate(false) - } - - // Two-hand rotation: rotate the camera around the target - if (Math.abs(gestureState.rotateDelta) > 0.01) { - // Convert rotation delta to camera orbit - const rotateSpeed = 2 - controls.object.position.applyAxisAngle( - new THREE.Vector3(0, 1, 0), - gestureState.rotateDelta * rotateSpeed - ) - setAutoRotate(false) - } - - // Two-hand pan: move target based on center movement - if ( - Math.abs(gestureState.panDelta.x) > 0.001 || - Math.abs(gestureState.panDelta.y) > 0.001 - ) { - const panSpeed = 50 - controls.target.x -= gestureState.panDelta.x * panSpeed - controls.target.y += gestureState.panDelta.y * panSpeed - setAutoRotate(false) + const leftRay = gestureState.leftPinchRay + const rightRay = gestureState.rightPinchRay + + // Two-hand pinch manipulation: Both hands must be gripping + const bothGripping = leftRay?.isValid && rightRay?.isValid + + if (bothGripping && leftRay && rightRay) { + const prev = prevPinchStateRef.current + + // Calculate current pinch positions + const leftOrigin = leftRay.origin + const rightOrigin = rightRay.origin + + // Distance between pinch points + const dx = rightOrigin.x - leftOrigin.x + const dy = rightOrigin.y - leftOrigin.y + const currentDistance = Math.sqrt(dx * dx + dy * dy) + + // Rotation angle between pinch points + const currentRotation = Math.atan2(dy, dx) + + // Center point between pinch origins + const currentCenter = { + x: (leftOrigin.x + rightOrigin.x) / 2, + y: (leftOrigin.y + rightOrigin.y) / 2, + } + + // Average Z depth (for pull gesture) + const currentZ = (leftOrigin.z + rightOrigin.z) / 2 + + // Only apply deltas if we have valid previous state + if (prev.leftOrigin && prev.rightOrigin) { + // ZOOM: Spread hands apart = zoom in, pinch together = zoom out + const distanceDelta = currentDistance - prev.distance + if (Math.abs(distanceDelta) > 0.002) { + // Spread = positive = zoom in (move camera closer) + const zoomFactor = 1 - distanceDelta * 3 + controls.object.position.multiplyScalar(zoomFactor) + setAutoRotate(false) + } + + // ROTATE: Rotate hands around each other = orbit camera + let rotationDelta = currentRotation - prev.rotation + // Normalize angle + while (rotationDelta > Math.PI) rotationDelta -= 2 * Math.PI + while (rotationDelta < -Math.PI) rotationDelta += 2 * Math.PI + + if (Math.abs(rotationDelta) > 0.01) { + const rotateSpeed = 2 + controls.object.position.applyAxisAngle( + new THREE.Vector3(0, 1, 0), + rotationDelta * rotateSpeed + ) + setAutoRotate(false) + } + + // PAN: Move both hands together = pan the view + const panDeltaX = currentCenter.x - prev.center.x + const panDeltaY = currentCenter.y - prev.center.y + if (Math.abs(panDeltaX) > 0.002 || Math.abs(panDeltaY) > 0.002) { + const panSpeed = 100 + controls.target.x -= panDeltaX * panSpeed + controls.target.y += panDeltaY * panSpeed + setAutoRotate(false) + } + + // PULL: Move both hands toward/away from camera = dolly + const prevZ = (prev.leftOrigin.z + prev.rightOrigin.z) / 2 + const zDelta = currentZ - prevZ + if (Math.abs(zDelta) > 0.005) { + // Negative Z = toward camera = pull graph closer (zoom in) + // Positive Z = away from camera = push graph away (zoom out) + const pullFactor = 1 + zDelta * 5 + controls.object.position.multiplyScalar(pullFactor) + setAutoRotate(false) + } + } + + // Update previous state + prevPinchStateRef.current = { + leftOrigin, + rightOrigin, + distance: currentDistance, + rotation: currentRotation, + center: currentCenter, + } + + controls.update() + } else { + // Reset previous state when not both gripping + prevPinchStateRef.current = { + leftOrigin: null, + rightOrigin: null, + distance: 0, + rotation: 0, + center: { x: 0.5, y: 0.5 }, + } + + // Fallback to wrist-based gestures when two hands detected but not both gripping + if (gestureState.handsDetected >= 2) { + // Two-hand zoom: spread = zoom in, pinch = zoom out + if (Math.abs(gestureState.zoomDelta) > 0.001) { + const zoomFactor = 1 - gestureState.zoomDelta * 0.5 + controls.object.position.multiplyScalar(zoomFactor) + setAutoRotate(false) + } + + // Two-hand rotation + if (Math.abs(gestureState.rotateDelta) > 0.01) { + const rotateSpeed = 2 + controls.object.position.applyAxisAngle( + new THREE.Vector3(0, 1, 0), + gestureState.rotateDelta * rotateSpeed + ) + setAutoRotate(false) + } + + // Two-hand pan + if ( + Math.abs(gestureState.panDelta.x) > 0.001 || + Math.abs(gestureState.panDelta.y) > 0.001 + ) { + const panSpeed = 50 + controls.target.x -= gestureState.panDelta.x * panSpeed + controls.target.y += gestureState.panDelta.y * panSpeed + setAutoRotate(false) + } + + controls.update() + } } - - controls.update() }, [gestureControlEnabled, gestureState]) return ( From 9fbff2d4876377962c4d61002b0e1b6681bb9fb9 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 06:33:31 +0100 Subject: [PATCH 12/47] perf(graph-viewer): major performance optimizations for 500+ nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance optimizations implemented: - Instanced mesh rendering for nodes (1 draw call for all nodes) - Batched LineSegments for edges (1 draw call for all edges) - Reduced sphere geometry from 32x32 to 12x12 segments - LOD labels: only show labels for nearby/selected nodes (max 10) - Performance mode toggle: disables Bloom/Vignette post-processing - Single useFrame callback instead of per-node callbacks - Reusable temp objects to avoid GC pressure - Frustum culling enabled on instanced mesh Before: ~100 draw calls, 200 useFrame callbacks, 100k vertices After: ~3 draw calls, 3 useFrame callbacks, shared geometry 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/graph-viewer/src/App.tsx | 29 + .../src/components/GraphCanvas.tsx | 620 ++++++++++++------ 2 files changed, 434 insertions(+), 215 deletions(-) diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 8a9c836..0575d63 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -65,6 +65,15 @@ function BugIcon({ className }: { className?: string }) { ) } +// Bolt/Performance icon SVG component +function BoltIcon({ className }: { className?: string }) { + return ( + + + + ) +} + export default function App() { const { setToken, isAuthenticated } = useAuth() const [selectedNode, setSelectedNode] = useState(null) @@ -72,6 +81,7 @@ export default function App() { const [searchTerm, setSearchTerm] = useState('') const [gestureControlEnabled, setGestureControlEnabled] = useState(false) const [debugOverlayVisible, setDebugOverlayVisible] = useState(false) + const [performanceMode, setPerformanceMode] = useState(false) const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) const [filters, setFilters] = useState({ types: [], @@ -137,6 +147,24 @@ export default function App() { + {/* Performance Mode Toggle */} + + {/* Gesture Control Toggle */} +
+ + {/* Content */} +

+ {node.content} +

+ + {/* Tags */} + {node.tags.length > 0 && ( +
+ {node.tags.slice(0, 6).map((tag, i) => ( + + #{tag} + + ))} + {node.tags.length > 6 && ( + + +{node.tags.length - 6} more + + )} +
+ )} + + {/* Metadata */} +
+
+ Importance +
+
+
+
+ {(node.importance * 100).toFixed(0)}% +
+
+
+ Created +
+ {new Date(node.timestamp).toLocaleDateString()} +
+
+
+
+ ) +} + +/** + * Floating particles for visual ambiance + */ +interface FloatingParticlesProps { + count: number + radius: number + color: string + progress: number +} + +function FloatingParticles({ count, radius, color, progress }: FloatingParticlesProps) { + const particlesRef = useRef(null) + + const particles = useMemo(() => { + const positions = new Float32Array(count * 3) + const sizes = new Float32Array(count) + const phases = new Float32Array(count) + + for (let i = 0; i < count; i++) { + const theta = Math.random() * Math.PI * 2 + const phi = Math.acos(2 * Math.random() - 1) + const r = radius * (0.5 + Math.random() * 0.5) + + positions[i * 3] = r * Math.sin(phi) * Math.cos(theta) + positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta) + positions[i * 3 + 2] = r * Math.cos(phi) + + sizes[i] = 0.1 + Math.random() * 0.2 + phases[i] = Math.random() * Math.PI * 2 + } + + return { positions, sizes, phases } + }, [count, radius]) + + useFrame((state) => { + if (!particlesRef.current) return + + const positions = particlesRef.current.geometry.attributes.position.array as Float32Array + const time = state.clock.elapsedTime + + for (let i = 0; i < count; i++) { + const phase = particles.phases[i] + const drift = Math.sin(time + phase) * 0.5 + + // Orbit slowly + const angle = time * 0.2 + phase + const r = radius * (0.5 + Math.sin(time * 0.5 + phase) * 0.2) + + positions[i * 3] = r * Math.cos(angle) * Math.sin(phase) + positions[i * 3 + 1] = r * Math.sin(angle) * Math.cos(phase) + drift + positions[i * 3 + 2] = r * Math.cos(angle) * Math.cos(phase) + } + + particlesRef.current.geometry.attributes.position.needsUpdate = true + }) + + return ( + + + + + + + ) +} + +export default ExpandedNodeView diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 68a372f..b118377 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -8,6 +8,13 @@ * - LOD for labels (only show labels for nearby/selected nodes) * - Optional post-processing (performance mode toggle) * - Single useFrame callback for all animations + * + * Hand interaction features: + * - Stable pointer ray with arm model + One Euro Filter + * - Accurate ray-sphere intersection for node selection + * - Pinch-to-select with expansion animation + * - Pull/push gestures for Z manipulation + * - Two-hand rotation and zoom */ import { useRef, useMemo, useState, useCallback, useEffect } from 'react' @@ -17,8 +24,12 @@ import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing' import * as THREE from 'three' import { useForceLayout } from '../hooks/useForceLayout' import { useHandGestures, GestureState } from '../hooks/useHandGestures' +import { useHandInteraction } from '../hooks/useHandInteraction' +import { LaserPointer } from './LaserPointer' +import { ExpandedNodeView } from './ExpandedNodeView' import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' +import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' // Performance constants const SPHERE_SEGMENTS = 12 // Reduced from 32 - good enough for small spheres @@ -106,10 +117,51 @@ function Scene({ performanceMode, }: SceneProps) { const { nodes: layoutNodes, isSimulating } = useForceLayout({ nodes, edges }) - const [autoRotate, setAutoRotate] = useState(false) // Start still, not rotating + const [autoRotate, setAutoRotate] = useState(false) const groupRef = useRef(null) const controlsRef = useRef(null) + // Expanded node state (for the bloom animation) + const [expandedNodeId, setExpandedNodeId] = useState(null) + const [hitPoint, setHitPoint] = useState<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }) + const [isExpanding, setIsExpanding] = useState(false) + + // Hand interaction with stable pointer ray + const { interactionState, processGestures } = useHandInteraction({ + nodes: layoutNodes, + onNodeSelect: (nodeId) => { + if (nodeId) { + // Find the node and trigger expansion + const node = layoutNodes.find(n => n.id === nodeId) + if (node) { + setExpandedNodeId(nodeId) + setHitPoint({ + x: interactionState.hoveredNode?.point.x ?? node.x ?? 0, + y: interactionState.hoveredNode?.point.y ?? node.y ?? 0, + z: interactionState.hoveredNode?.point.z ?? node.z ?? 0, + }) + setIsExpanding(true) + } + onNodeSelect(node ?? null) + } else { + setExpandedNodeId(null) + setIsExpanding(false) + onNodeSelect(null) + } + }, + onNodeHover: (nodeId) => { + const node = nodeId ? layoutNodes.find(n => n.id === nodeId) ?? null : null + onNodeHover(node) + }, + }) + + // Process gestures each frame + useEffect(() => { + if (gestureControlEnabled && gestureState.isTracking) { + processGestures(gestureState) + } + }, [gestureControlEnabled, gestureState, processGestures]) + // Create node lookup for edges const nodeById = useMemo( () => new Map(layoutNodes.map((n) => [n.id, n])), @@ -143,11 +195,34 @@ function Scene({ return ids }, [selectedNode, edges]) + // Get expanded node and its connections + const expandedNode = useMemo(() => { + if (!expandedNodeId) return null + return layoutNodes.find(n => n.id === expandedNodeId) ?? null + }, [expandedNodeId, layoutNodes]) + + const connectedToExpanded = useMemo(() => { + if (!expandedNodeId) return [] + const connectedNodeIds = new Set() + edges.forEach(e => { + if (e.source === expandedNodeId) connectedNodeIds.add(e.target) + if (e.target === expandedNodeId) connectedNodeIds.add(e.source) + }) + return layoutNodes.filter(n => connectedNodeIds.has(n.id)) + }, [expandedNodeId, edges, layoutNodes]) + // Stop auto-rotate on user interaction const handleInteractionStart = useCallback(() => { setAutoRotate(false) }, []) + // Close expanded node + const handleCloseExpanded = useCallback(() => { + setExpandedNodeId(null) + setIsExpanding(false) + onNodeSelect(null) + }, [onNodeSelect]) + // Track previous pinch state for delta calculations (per hand) const prevPinchStateRef = useRef<{ left: { origin: { x: number; y: number; z: number }; strength: number } | null @@ -168,108 +243,38 @@ function Scene({ const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) // Apply gesture controls to move the CLOUD (not camera) with smoothing - // Single hand: Pinch + pull/push = translate cloud in Z - // Two hands: Compound forces at intersection = 3D rotation + // Now also uses the new interaction state for more precise control useEffect(() => { if (!gestureControlEnabled || !groupRef.current) return if (!gestureState.isTracking) return const group = groupRef.current - const leftRay = gestureState.leftPinchRay - const rightRay = gestureState.rightPinchRay const smoothed = smoothedGestureRef.current - const prev = prevPinchStateRef.current - - const leftGripping = leftRay?.isValid - const rightGripping = rightRay?.isValid - const bothGripping = leftGripping && rightGripping - - let totalTranslateZ = 0 - let totalRotateX = 0 - let totalRotateY = 0 - - // Process left hand - if (leftGripping && leftRay) { - const currentOrigin = leftRay.origin - const currentStrength = leftRay.strength - - if (prev.left) { - const zDelta = currentOrigin.z - prev.left.origin.z - // Pull back (Z increases) = bring cloud closer (positive Z) - totalTranslateZ += -zDelta * PULL_SENSITIVITY * currentStrength - - if (bothGripping) { - // For two-hand mode: left hand contributes to rotation - // Movement in X affects Y rotation, movement in Y affects X rotation - const xDelta = currentOrigin.x - prev.left.origin.x - const yDelta = currentOrigin.y - prev.left.origin.y - totalRotateY += xDelta * 2 * currentStrength - totalRotateX += -yDelta * 2 * currentStrength - } - } - prev.left = { origin: { ...currentOrigin }, strength: currentStrength } - } else { - prev.left = null - } + // Use the new interaction state for cloud manipulation + const { rotationDelta, zoomDelta, dragDeltaZ, isDragging } = interactionState - // Process right hand - if (rightGripping && rightRay) { - const currentOrigin = rightRay.origin - const currentStrength = rightRay.strength - - if (prev.right) { - const zDelta = currentOrigin.z - prev.right.origin.z - // Pull back (Z increases) = bring cloud closer (positive Z) - totalTranslateZ += -zDelta * PULL_SENSITIVITY * currentStrength - - if (bothGripping) { - // For two-hand mode: right hand contributes to rotation (opposite direction) - // This creates compound rotation at the intersection point - const xDelta = currentOrigin.x - prev.right.origin.x - const yDelta = currentOrigin.y - prev.right.origin.y - // Right hand rotates opposite to left - creates torque effect - totalRotateY -= xDelta * 2 * currentStrength - totalRotateX += -yDelta * 2 * currentStrength - } - } - - prev.right = { origin: { ...currentOrigin }, strength: currentStrength } - } else { - prev.right = null + // Apply zoom (two-hand spread/pinch) + if (Math.abs(zoomDelta) > GESTURE_DEADZONE) { + group.position.z += zoomDelta * 0.5 } - // Apply smoothing - smoothed.translateZ += (totalTranslateZ - smoothed.translateZ) * GESTURE_SMOOTHING - smoothed.rotateX += (totalRotateX - smoothed.rotateX) * GESTURE_SMOOTHING - smoothed.rotateY += (totalRotateY - smoothed.rotateY) * GESTURE_SMOOTHING - - // Clamp to max speeds - const translateZ = clamp(smoothed.translateZ, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) - const rotateX = clamp(smoothed.rotateX, -MAX_ROTATE_SPEED, MAX_ROTATE_SPEED) - const rotateY = clamp(smoothed.rotateY, -MAX_ROTATE_SPEED, MAX_ROTATE_SPEED) - - // Apply translation (single or both hands) - if (Math.abs(translateZ) > GESTURE_DEADZONE) { - group.position.z += translateZ + // Apply rotation (two-hand rotation) + if (Math.abs(rotationDelta.x) > GESTURE_DEADZONE) { + group.rotation.z += rotationDelta.x } - // Apply rotation (only with two hands - compound effect) - if (bothGripping) { - if (Math.abs(rotateX) > GESTURE_DEADZONE) { - group.rotation.x += rotateX - } - if (Math.abs(rotateY) > GESTURE_DEADZONE) { - group.rotation.y += rotateY - } + // Apply Z drag (single hand push/pull when not selecting a node) + if (!isDragging && Math.abs(dragDeltaZ) > GESTURE_DEADZONE) { + smoothed.translateZ += (dragDeltaZ - smoothed.translateZ) * GESTURE_SMOOTHING + const clamped = clamp(smoothed.translateZ, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) + group.position.z += clamped } - // Decay smoothed values when no hands gripping - if (!leftGripping && !rightGripping) { - smoothed.translateZ *= 0.9 - smoothed.rotateX *= 0.9 - smoothed.rotateY *= 0.9 - } + // Decay smoothed values + smoothed.translateZ *= 0.9 + smoothed.rotateX *= 0.9 + smoothed.rotateY *= 0.9 // Gentle recenter: slowly pull cloud back toward origin group.position.x *= (1 - RECENTER_STRENGTH) @@ -277,7 +282,7 @@ function Scene({ group.position.z *= (1 - RECENTER_STRENGTH) group.rotation.x *= (1 - RECENTER_STRENGTH) group.rotation.y *= (1 - RECENTER_STRENGTH) - }, [gestureControlEnabled, gestureState]) + }, [gestureControlEnabled, gestureState, interactionState]) return ( <> @@ -328,8 +333,42 @@ function Scene({ searchTerm={searchTerm} matchingIds={matchingIds} /> + + {/* Expanded Node View - shows when a node is selected via hand */} + {expandedNode && ( + + )} + {/* Laser pointers - rendered in world space for accurate targeting */} + {gestureControlEnabled && interactionState.leftRay && ( + (interactionState.leftRay.confidence ?? 0) + ? null + : interactionState.hoveredNode} + color="#4ecdc4" + showArmModel={false} + /> + )} + {gestureControlEnabled && interactionState.rightRay && ( + = (interactionState.leftRay?.confidence ?? 0) + ? interactionState.hoveredNode + : null} + color="#f72585" + showArmModel={false} + /> + )} + {/* Post-processing effects - conditional based on performance mode */} {!performanceMode && ( diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 6f625fa..b1e7157 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -4,13 +4,15 @@ * Renders hands as a 2D overlay on top of the canvas with: * - Ghost 3D hand effect (translucent, glowing) * - Smoothing/interpolation (ghost persists when hand disappears) - * - Laser beams that default toward center with slight deviation + * - Accurate laser beams using stable pointer ray with arm model + * - Hit indicator when pointing at a node * - Pinch grip indicator (lights up when gripped) * - Support for two-hand manipulation */ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import type { GestureState, PinchRay } from '../hooks/useHandGestures' +import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' // Fingertip indices const FINGERTIPS = [4, 8, 12, 16, 20] @@ -38,9 +40,22 @@ interface Hand2DOverlayProps { gestureState: GestureState enabled?: boolean showLaser?: boolean + /** Stable left ray from arm model + One Euro Filter */ + leftStableRay?: StableRay | null + /** Stable right ray from arm model + One Euro Filter */ + rightStableRay?: StableRay | null + /** Current node hit (if any) */ + hoveredNode?: NodeHit | null } -export function Hand2DOverlay({ gestureState, enabled = true, showLaser = true }: Hand2DOverlayProps) { +export function Hand2DOverlay({ + gestureState, + enabled = true, + showLaser = true, + leftStableRay, + rightStableRay, + hoveredNode, +}: Hand2DOverlayProps) { // Track smoothed hand positions with ghost effect const [leftSmoothed, setLeftSmoothed] = useState(null) const [rightSmoothed, setRightSmoothed] = useState(null) @@ -222,21 +237,27 @@ export function Hand2DOverlay({ gestureState, enabled = true, showLaser = true } /> )} - {/* Left laser */} + {/* Left laser - use stable ray if available, otherwise fall back to basic ray */} {showLaser && gestureState.leftPinchRay && gestureState.leftPinchRay.strength > 0.3 && ( = (rightStableRay?.confidence ?? 0)} /> )} - {/* Right laser */} + {/* Right laser - use stable ray if available */} {showLaser && gestureState.rightPinchRay && gestureState.rightPinchRay.strength > 0.3 && ( (leftStableRay?.confidence ?? 0)} /> )} @@ -504,49 +525,63 @@ function GhostHand({ landmarks, color: _color, gradientId: _gradientId, isGhost interface LaserBeamProps { ray: PinchRay + stableRay?: StableRay | null color: string isGripped: boolean + hasHit?: boolean } -function LaserBeam({ ray, color, isGripped }: LaserBeamProps) { - // Un-mirror the X coordinate (webcam is mirrored, so flip it back) - const originX = (1 - ray.origin.x) * 100 - const originY = ray.origin.y * 100 - - // Center of screen (the nexus) - const centerX = 50 - const centerY = 50 - - // Calculate direction: blend between center and hand-influenced direction - // Hand position deviation from center - const handDeviationX = (originX - 50) * LASER_DEVIATION_SCALE - const handDeviationY = (originY - 50) * LASER_DEVIATION_SCALE - - // Target point: mostly center, slightly influenced by hand position - const targetX = centerX - handDeviationX * (1 - LASER_CENTER_BIAS) - const targetY = centerY - handDeviationY * (1 - LASER_CENTER_BIAS) - - // Direction toward target - const toCenterX = targetX - originX - const toCenterY = targetY - originY - const dist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) - const normX = dist > 0 ? toCenterX / dist : 0 - const normY = dist > 0 ? toCenterY / dist : 0 - - // Laser extends to target (the nexus area) - const laserLength = dist - const endX = originX + normX * laserLength - const endY = originY + normY * laserLength - - // Visual properties - much more intense when gripped - const baseStrokeWidth = 0.2 + ray.strength * 0.3 - const strokeWidth = isGripped ? baseStrokeWidth * 2.5 : baseStrokeWidth - const baseOpacity = 0.3 + ray.strength * 0.4 - const opacity = isGripped ? Math.min(1, baseOpacity * 1.8) : baseOpacity - const glowRadius = isGripped ? 2 + ray.strength * 2 : 0.8 + ray.strength * 0.8 - - // Grip indicator color - brighter white-ish when gripped - const gripColor = isGripped ? '#ffffff' : color +function LaserBeam({ ray, stableRay, color, isGripped, hasHit = false }: LaserBeamProps) { + // Use stable ray screen hit if available, otherwise fall back to basic calculation + let originX: number, originY: number, endX: number, endY: number + + if (stableRay?.screenHit) { + // Use the stable ray (arm model + One Euro Filter) + // Un-mirror the X coordinate (webcam is mirrored) + originX = (1 - stableRay.pinchPoint.x) * 100 + originY = stableRay.pinchPoint.y * 100 + + // End point from ray intersection with screen plane + endX = (1 - stableRay.screenHit.x) * 100 + endY = stableRay.screenHit.y * 100 + + // Clamp to viewport + endX = Math.max(0, Math.min(100, endX)) + endY = Math.max(0, Math.min(100, endY)) + } else { + // Fallback: original basic ray calculation + originX = (1 - ray.origin.x) * 100 + originY = ray.origin.y * 100 + + const centerX = 50 + const centerY = 50 + const handDeviationX = (originX - 50) * LASER_DEVIATION_SCALE + const handDeviationY = (originY - 50) * LASER_DEVIATION_SCALE + const targetX = centerX - handDeviationX * (1 - LASER_CENTER_BIAS) + const targetY = centerY - handDeviationY * (1 - LASER_CENTER_BIAS) + + const toCenterX = targetX - originX + const toCenterY = targetY - originY + const dist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) + const normX = dist > 0 ? toCenterX / dist : 0 + const normY = dist > 0 ? toCenterY / dist : 0 + const laserLength = dist + + endX = originX + normX * laserLength + endY = originY + normY * laserLength + } + + // Visual properties - intensify based on state + const pinchStrength = stableRay?.pinchStrength ?? ray.strength + const baseStrokeWidth = 0.2 + pinchStrength * 0.3 + const strokeWidth = isGripped ? baseStrokeWidth * 2.5 : hasHit ? baseStrokeWidth * 1.5 : baseStrokeWidth + const baseOpacity = 0.3 + pinchStrength * 0.4 + const opacity = isGripped ? Math.min(1, baseOpacity * 1.8) : hasHit ? baseOpacity * 1.3 : baseOpacity + const glowRadius = isGripped ? 2 + pinchStrength * 2 : hasHit ? 1.5 + pinchStrength : 0.8 + pinchStrength * 0.8 + + // Color changes based on state + const activeColor = isGripped ? '#ffffff' : hasHit ? '#fbbf24' : color // Golden when hitting + const confidence = stableRay?.confidence ?? 0.8 return ( @@ -582,10 +617,10 @@ function LaserBeam({ ray, color, isGripped }: LaserBeamProps) { y1={originY} x2={endX} y2={endY} - stroke={gripColor} + stroke={activeColor} strokeWidth={strokeWidth} strokeLinecap="round" - opacity={opacity} + opacity={opacity * confidence} /> {/* Origin glow sphere */} @@ -600,10 +635,39 @@ function LaserBeam({ ray, color, isGripped }: LaserBeamProps) { cx={originX} cy={originY} r={glowRadius} - fill={gripColor} - opacity={isGripped ? 1 : 0.7} + fill={activeColor} + opacity={isGripped ? 1 : hasHit ? 0.9 : 0.7} /> + {/* Hit indicator - pulsing crosshair when pointing at a node */} + {hasHit && ( + + {/* Outer ring */} + + {/* Inner target */} + + {/* Crosshair lines */} + + + + + + )} + {/* Impact point at center - "warm spot" where laser hits the nexus */} (null) + const hitGlowRef = useRef(null) + const rippleRef = useRef(null) + + // Laser beam points + const beamPoints = useMemo(() => { + const start: [number, number, number] = [ + ray.origin.x, + ray.origin.y, + ray.origin.z, + ] + + // End point: either hit point or extend ray into distance + const maxDistance = 200 + const distance = hit ? hit.distance : maxDistance + const end: [number, number, number] = [ + ray.origin.x + ray.direction.x * distance, + ray.origin.y + ray.direction.y * distance, + ray.origin.z + ray.direction.z * distance, + ] + + return { start, end, distance } + }, [ray, hit]) + + // Animate glow effects + useFrame((state) => { + const time = state.clock.elapsedTime + + // Pulse the origin glow + if (glowRef.current) { + const pulse = 1 + Math.sin(time * 4) * 0.15 + const baseScale = 0.15 + ray.pinchStrength * 0.1 + glowRef.current.scale.setScalar(baseScale * pulse) + } + + // Pulse the hit indicator + if (hitGlowRef.current && hit) { + const pulse = 1 + Math.sin(time * 6) * 0.2 + hitGlowRef.current.scale.setScalar(0.8 * pulse) + } + + // Rotate ripple effect + if (rippleRef.current && ray.isActive) { + rippleRef.current.rotation.z = time * 2 + } + }) + + // Visual properties based on state + const intensity = ray.isActive ? 1 : 0.4 + ray.pinchStrength * 0.4 + const lineWidth = ray.isActive ? 3 : 1.5 + ray.pinchStrength + const glowOpacity = 0.3 + intensity * 0.4 + + return ( + + {/* Main beam - inner bright core */} + + + {/* Outer glow beam */} + + + {/* Origin glow sphere */} + + + + + + {/* Active ripple at origin */} + {ray.isActive && ( + + + + + )} + + {/* Hit indicator */} + {hit && ( + + {/* Inner hit point */} + + + + + + {/* Outer glow */} + + + + + + {/* Expanding rings when active */} + {ray.isActive && ( + <> + + + + + )} + + )} + + {/* Debug: Arm model visualization */} + {showArmModel && ( + + )} + + ) +} + +/** + * Expanding ring animation at hit point + */ +function ExpandingRing({ color, delay }: { color: string; delay: number }) { + const ringRef = useRef(null) + + useFrame((state) => { + if (!ringRef.current) return + + const time = (state.clock.elapsedTime + delay) % 1 + const scale = 0.5 + time * 2 + const opacity = (1 - time) * 0.4 + + ringRef.current.scale.setScalar(scale) + const mat = ringRef.current.material as THREE.MeshBasicMaterial + mat.opacity = opacity + }) + + return ( + + + + + ) +} + +/** + * Debug visualization of the estimated arm model + */ +function ArmModelDebug({ armPose, color }: { armPose: StableRay['armPose']; color: string }) { + const { shoulder, elbow, wrist, pinchPoint } = armPose + + return ( + + {/* Shoulder */} + + + + + + {/* Upper arm (shoulder → elbow) */} + + + {/* Elbow */} + + + + + + {/* Forearm (elbow → wrist) */} + + + {/* Wrist */} + + + + + + {/* Hand (wrist → pinch point) */} + + + {/* Pinch point */} + + + + + + ) +} + +export default LaserPointer diff --git a/packages/graph-viewer/src/hooks/useHandInteraction.ts b/packages/graph-viewer/src/hooks/useHandInteraction.ts new file mode 100644 index 0000000..d5f3100 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandInteraction.ts @@ -0,0 +1,260 @@ +/** + * Hand Interaction Hook + * + * Combines gesture tracking with stable pointer ray and hit detection. + * Provides a complete interaction system for the memory graph: + * + * - Accurate laser pointing with arm model + * - Node hit detection + * - Pinch-to-select with hysteresis + * - Pull/push gestures for Z manipulation + * - Two-hand rotation + * + * This is the main entry point for hand-based graph interaction. + */ + +import { useRef, useCallback, useEffect, useState } from 'react' +import { useStablePointerRay, findNodeHit, type StableRay, type NodeHit, type NodeSphere } from './useStablePointerRay' +import type { GestureState, HandLandmarks } from './useHandGestures' +import type { SimulationNode } from '../lib/types' + +export interface InteractionState { + /** Left hand ray (if tracking) */ + leftRay: StableRay | null + /** Right hand ray (if tracking) */ + rightRay: StableRay | null + /** Currently hovered node (ray intersects) */ + hoveredNode: NodeHit | null + /** Selected node (pinch activated on hover) */ + selectedNodeId: string | null + /** Is a node being dragged */ + isDragging: boolean + /** Drag delta for Z manipulation */ + dragDeltaZ: number + /** Two-hand rotation delta */ + rotationDelta: { x: number; y: number } + /** Two-hand zoom delta */ + zoomDelta: number +} + +interface UseHandInteractionOptions { + /** Nodes to test for hit detection */ + nodes: SimulationNode[] + /** Callback when node selection changes */ + onNodeSelect?: (nodeId: string | null) => void + /** Callback when node hover changes */ + onNodeHover?: (nodeId: string | null) => void +} + +// Sensitivity settings +const DRAG_Z_SENSITIVITY = 80 +const ROTATION_SENSITIVITY = 2 +const ZOOM_SENSITIVITY = 150 + +export function useHandInteraction({ nodes, onNodeSelect, onNodeHover }: UseHandInteractionOptions) { + // Stable pointer ray processors for each hand + const leftRayProcessor = useStablePointerRay({ handedness: 'left' }) + const rightRayProcessor = useStablePointerRay({ handedness: 'right' }) + + // State + const [interactionState, setInteractionState] = useState({ + leftRay: null, + rightRay: null, + hoveredNode: null, + selectedNodeId: null, + isDragging: false, + dragDeltaZ: 0, + rotationDelta: { x: 0, y: 0 }, + zoomDelta: 0, + }) + + // Previous state for delta calculations + const prevStateRef = useRef<{ + leftZ: number | null + rightZ: number | null + selectedNodeId: string | null + twoHandDistance: number | null + twoHandRotation: number | null + }>({ + leftZ: null, + rightZ: null, + selectedNodeId: null, + twoHandDistance: null, + twoHandRotation: null, + }) + + // Convert SimulationNodes to NodeSpheres for hit testing + const nodeSpheres: NodeSphere[] = nodes.map(n => ({ + id: n.id, + x: n.x ?? 0, + y: n.y ?? 0, + z: n.z ?? 0, + radius: n.radius * 1.5, // Slightly larger hit area + })) + + // Process gesture state and update interaction state + const processGestures = useCallback((gestureState: GestureState) => { + const timestamp = performance.now() / 1000 + const prev = prevStateRef.current + + // Process hand landmarks through stable ray pipeline + const leftRay = gestureState.leftHand + ? leftRayProcessor.processLandmarks(gestureState.leftHand.landmarks, timestamp) + : null + + const rightRay = gestureState.rightHand + ? rightRayProcessor.processLandmarks(gestureState.rightHand.landmarks, timestamp) + : null + + // Determine primary ray (prefer right hand) + const primaryRay = rightRay?.confidence > (leftRay?.confidence ?? 0) ? rightRay : leftRay + + // Find node hit + let hoveredNode: NodeHit | null = null + if (primaryRay) { + // Convert normalized ray to world coordinates for hit testing + // The ray is in normalized 0-1 space, nodes are in world space (-100 to 100 etc) + const worldRay = { + origin: { + x: (primaryRay.origin.x - 0.5) * 200, + y: -(primaryRay.origin.y - 0.5) * 200, + z: -primaryRay.origin.z * 200, + }, + direction: { + x: primaryRay.direction.x, + y: -primaryRay.direction.y, // Flip Y for world coords + z: -primaryRay.direction.z, + }, + } + hoveredNode = findNodeHit(worldRay, nodeSpheres, 500) + } + + // Handle selection (pinch on hover) + let selectedNodeId = prev.selectedNodeId + let isDragging = false + let dragDeltaZ = 0 + + if (primaryRay?.isActive) { + if (hoveredNode && !prev.selectedNodeId) { + // Start selection + selectedNodeId = hoveredNode.nodeId + onNodeSelect?.(selectedNodeId) + } + + if (selectedNodeId) { + isDragging = true + + // Calculate Z drag from hand movement + const currentZ = primaryRay.origin.z + const prevZ = primaryRay === leftRay ? prev.leftZ : prev.rightZ + + if (prevZ !== null) { + // Negative Z movement (hand toward camera) = push node away + dragDeltaZ = (currentZ - prevZ) * DRAG_Z_SENSITIVITY + } + } + } else { + // Released - clear selection + if (prev.selectedNodeId) { + selectedNodeId = null + onNodeSelect?.(null) + } + } + + // Update hover callback + if (hoveredNode?.nodeId !== prev.selectedNodeId) { + // Don't update hover while dragging the same node + const hoverId = hoveredNode?.nodeId ?? null + const prevHoverId = interactionState.hoveredNode?.nodeId ?? null + if (hoverId !== prevHoverId && !isDragging) { + onNodeHover?.(hoverId) + } + } + + // Two-hand gestures + let rotationDelta = { x: 0, y: 0 } + let zoomDelta = 0 + + if (leftRay?.isActive && rightRay?.isActive) { + // Both hands pinching - two-hand mode + + // Calculate distance between pinch points + const dx = rightRay.pinchPoint.x - leftRay.pinchPoint.x + const dy = rightRay.pinchPoint.y - leftRay.pinchPoint.y + const distance = Math.sqrt(dx * dx + dy * dy) + + // Calculate rotation angle + const rotation = Math.atan2(dy, dx) + + if (prev.twoHandDistance !== null && prev.twoHandRotation !== null) { + // Zoom from distance change + zoomDelta = (distance - prev.twoHandDistance) * ZOOM_SENSITIVITY + + // Rotation from angle change + let rotDelta = rotation - prev.twoHandRotation + // Normalize to -PI to PI + while (rotDelta > Math.PI) rotDelta -= Math.PI * 2 + while (rotDelta < -Math.PI) rotDelta += Math.PI * 2 + + rotationDelta = { + x: rotDelta * ROTATION_SENSITIVITY, + y: 0, // Y rotation from individual hand movements, handled above + } + } + + prev.twoHandDistance = distance + prev.twoHandRotation = rotation + } else { + prev.twoHandDistance = null + prev.twoHandRotation = null + } + + // Update previous state + prev.leftZ = leftRay?.origin.z ?? null + prev.rightZ = rightRay?.origin.z ?? null + prev.selectedNodeId = selectedNodeId + + // Update interaction state + setInteractionState({ + leftRay, + rightRay, + hoveredNode, + selectedNodeId, + isDragging, + dragDeltaZ, + rotationDelta, + zoomDelta, + }) + }, [nodeSpheres, leftRayProcessor, rightRayProcessor, onNodeSelect, onNodeHover]) + + // Reset + const reset = useCallback(() => { + leftRayProcessor.reset() + rightRayProcessor.reset() + prevStateRef.current = { + leftZ: null, + rightZ: null, + selectedNodeId: null, + twoHandDistance: null, + twoHandRotation: null, + } + setInteractionState({ + leftRay: null, + rightRay: null, + hoveredNode: null, + selectedNodeId: null, + isDragging: false, + dragDeltaZ: 0, + rotationDelta: { x: 0, y: 0 }, + zoomDelta: 0, + }) + }, [leftRayProcessor, rightRayProcessor]) + + return { + interactionState, + processGestures, + reset, + } +} + +export default useHandInteraction diff --git a/packages/graph-viewer/src/hooks/useStablePointerRay.ts b/packages/graph-viewer/src/hooks/useStablePointerRay.ts new file mode 100644 index 0000000..c2b8e7c --- /dev/null +++ b/packages/graph-viewer/src/hooks/useStablePointerRay.ts @@ -0,0 +1,400 @@ +/** + * Stable Pointer Ray Hook + * + * Implements Meta Quest-style pointer ray with: + * - Estimated arm model (shoulder → elbow → wrist → pinch) + * - Virtual pivot point behind wrist for stability + * - One Euro Filter for velocity-adaptive smoothing + * - Ray-sphere intersection for node hit detection + * + * The key insight: humans point with their forearm, not their hand. + * Small hand tremors cause huge angular changes if you pivot at the wrist. + * By estimating the elbow and placing the pivot further back, we reduce jitter. + */ + +import { useRef, useCallback, useMemo } from 'react' +import { PointerRayFilter, type PointerRay } from '../lib/OneEuroFilter' +import type { NormalizedLandmarkList } from '@mediapipe/hands' + +// MediaPipe landmark indices +const WRIST = 0 +const THUMB_TIP = 4 +const THUMB_CMC = 1 // Base of thumb +const INDEX_TIP = 8 +const INDEX_MCP = 5 // Knuckle +const MIDDLE_MCP = 9 +const RING_MCP = 13 +const PINKY_MCP = 17 + +export interface Vec3 { + x: number + y: number + z: number +} + +export interface StableRay extends PointerRay { + /** Pinch strength 0-1 */ + pinchStrength: number + /** Is ray valid for interaction (pinch > threshold) */ + isActive: boolean + /** Confidence in the ray direction */ + confidence: number + /** The pinch point (thumb-index midpoint) in normalized coords */ + pinchPoint: Vec3 + /** Screen intersection point (where laser hits the screen plane) */ + screenHit: { x: number; y: number } | null + /** Estimated arm pose for visualization */ + armPose: ArmPose +} + +export interface ArmPose { + shoulder: Vec3 + elbow: Vec3 + wrist: Vec3 + pinchPoint: Vec3 +} + +export interface NodeHit { + nodeId: string + distance: number + point: Vec3 +} + +interface UseStablePointerRayOptions { + /** Handedness - affects arm model */ + handedness: 'left' | 'right' + /** Pinch threshold to activate ray (0-1) */ + pinchThreshold?: number + /** Release threshold (should be lower than pinch for hysteresis) */ + releaseThreshold?: number + /** How far behind wrist to place virtual pivot (normalized units) */ + pivotDistance?: number +} + +// Vector math utilities +function sub(a: Vec3, b: Vec3): Vec3 { + return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z } +} + +function add(a: Vec3, b: Vec3): Vec3 { + return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z } +} + +function scale(v: Vec3, s: number): Vec3 { + return { x: v.x * s, y: v.y * s, z: v.z * s } +} + +function length(v: Vec3): number { + return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) +} + +function normalize(v: Vec3): Vec3 { + const len = length(v) + return len > 0 ? scale(v, 1 / len) : { x: 0, y: 0, z: -1 } +} + +function lerp3(a: Vec3, b: Vec3, t: number): Vec3 { + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + } +} + +function distance(a: Vec3, b: Vec3): number { + return length(sub(a, b)) +} + +/** + * Estimate pinch strength from thumb-index distance + * Returns 0 (open) to 1 (fully pinched) + */ +function calculatePinchStrength(landmarks: NormalizedLandmarkList): number { + const thumbTip = landmarks[THUMB_TIP] + const indexTip = landmarks[INDEX_TIP] + const dist = distance( + { x: thumbTip.x, y: thumbTip.y, z: thumbTip.z || 0 }, + { x: indexTip.x, y: indexTip.y, z: indexTip.z || 0 } + ) + // Typical range: 0.02 (pinched) to 0.15 (open) + return Math.max(0, Math.min(1, 1 - (dist - 0.02) / 0.13)) +} + +/** + * Estimate the arm pose from hand landmarks + * Since we can only see the hand, we infer shoulder/elbow positions + */ +function estimateArmPose(landmarks: NormalizedLandmarkList, handedness: 'left' | 'right'): ArmPose { + const wristLm = landmarks[WRIST] + const thumbTip = landmarks[THUMB_TIP] + const indexTip = landmarks[INDEX_TIP] + + const wrist: Vec3 = { + x: wristLm.x, + y: wristLm.y, + z: wristLm.z || 0, + } + + const pinchPoint: Vec3 = { + x: (thumbTip.x + indexTip.x) / 2, + y: (thumbTip.y + indexTip.y) / 2, + z: ((thumbTip.z || 0) + (indexTip.z || 0)) / 2, + } + + // Calculate palm orientation from MCP joints + const indexMcp = landmarks[INDEX_MCP] + const pinkyMcp = landmarks[PINKY_MCP] + const palmWidth = distance( + { x: indexMcp.x, y: indexMcp.y, z: indexMcp.z || 0 }, + { x: pinkyMcp.x, y: pinkyMcp.y, z: pinkyMcp.z || 0 } + ) + + // Estimate shoulder position - fixed relative to screen + // Shoulder is off-screen, on the same side as the hand + const isRight = handedness === 'right' + const shoulder: Vec3 = { + x: isRight ? 1.4 : -0.4, // Off screen + y: 1.5, // Below screen + z: 0.6, // Further from camera + } + + // Estimate elbow using anatomical constraints + // Elbow is roughly 35% of the way from shoulder to wrist + // with some lateral offset (natural arm bend) + const shoulderToWrist = sub(wrist, shoulder) + const forearmRatio = 0.35 + const lateralOffset = isRight ? 0.12 : -0.12 // Natural arm bend outward + + // Hand depth affects elbow estimation + // Hand closer to camera = elbow more bent (closer to body) + const depthFactor = Math.max(0, 0.5 - (wrist.z || 0)) + + const elbow: Vec3 = { + x: shoulder.x + shoulderToWrist.x * forearmRatio + lateralOffset * (1 + depthFactor), + y: shoulder.y + shoulderToWrist.y * forearmRatio - 0.08, + z: shoulder.z + shoulderToWrist.z * forearmRatio + depthFactor * 0.1, + } + + return { shoulder, elbow, wrist, pinchPoint } +} + +/** + * Calculate stable pointer ray using the arm model + */ +function calculateStableRay( + armPose: ArmPose, + pivotDistance: number +): PointerRay { + const { elbow, wrist, pinchPoint } = armPose + + // Forearm direction (elbow → wrist) + const forearmDir = normalize(sub(wrist, elbow)) + + // Virtual pivot: offset behind wrist along forearm + // This is the key to stability - small hand movements + // cause smaller angular changes when pivoting from further back + const pivot = sub(wrist, scale(forearmDir, pivotDistance)) + + // Ray direction: from virtual pivot through pinch point + const direction = normalize(sub(pinchPoint, pivot)) + + return { + origin: pivot, + direction, + } +} + +/** + * Calculate where the ray intersects the screen plane (z=0) + */ +function calculateScreenHit(ray: PointerRay): { x: number; y: number } | null { + const { origin, direction } = ray + + // Avoid division by zero + if (Math.abs(direction.z) < 0.0001) return null + + // t = -origin.z / direction.z (intersection with z=0 plane) + const t = -origin.z / direction.z + + // Only forward intersections + if (t < 0) return null + + return { + x: origin.x + direction.x * t, + y: origin.y + direction.y * t, + } +} + +/** + * Estimate confidence in the ray direction + * Based on hand visibility and pose stability + */ +function calculateConfidence(landmarks: NormalizedLandmarkList): number { + // Check visibility of key landmarks + const wrist = landmarks[WRIST] + const thumbTip = landmarks[THUMB_TIP] + const indexTip = landmarks[INDEX_TIP] + + // Visibility is 0-1 if present, otherwise assume low + const wristVis = (wrist as any).visibility ?? 0.5 + const thumbVis = (thumbTip as any).visibility ?? 0.5 + const indexVis = (indexTip as any).visibility ?? 0.5 + + // Check if hand is in reasonable pose (not twisted weirdly) + const indexMcp = landmarks[INDEX_MCP] + const middleMcp = landmarks[MIDDLE_MCP] + + // Palm should face camera - MCPs should be above wrist + const palmFacing = wrist.y > indexMcp.y && wrist.y > middleMcp.y + + const baseConfidence = (wristVis + thumbVis + indexVis) / 3 + const poseBonus = palmFacing ? 0.2 : 0 + + return Math.min(1, baseConfidence + poseBonus) +} + +export function useStablePointerRay(options: UseStablePointerRayOptions) { + const { + handedness, + pinchThreshold = 0.6, + releaseThreshold = 0.35, + pivotDistance = 0.12, + } = options + + // Filter for smoothing + const rayFilterRef = useRef(new PointerRayFilter()) + + // Track previous active state for hysteresis + const wasActiveRef = useRef(false) + + // Process hand landmarks and return stable ray + const processLandmarks = useCallback( + (landmarks: NormalizedLandmarkList | null, timestamp: number): StableRay | null => { + if (!landmarks) { + rayFilterRef.current.reset() + wasActiveRef.current = false + return null + } + + // Calculate pinch strength + const pinchStrength = calculatePinchStrength(landmarks) + + // Hysteresis: different thresholds for activating vs deactivating + const threshold = wasActiveRef.current ? releaseThreshold : pinchThreshold + const isActive = pinchStrength >= threshold + wasActiveRef.current = isActive + + // Estimate arm pose + const armPose = estimateArmPose(landmarks, handedness) + + // Calculate raw ray + const rawRay = calculateStableRay(armPose, pivotDistance) + + // Apply One Euro Filter for stability + const filteredRay = rayFilterRef.current.filter(rawRay, timestamp) + + // Calculate screen intersection + const screenHit = calculateScreenHit(filteredRay) + + // Estimate confidence + const confidence = calculateConfidence(landmarks) + + return { + ...filteredRay, + pinchStrength, + isActive, + confidence, + pinchPoint: armPose.pinchPoint, + screenHit, + armPose, + } + }, + [handedness, pinchThreshold, releaseThreshold, pivotDistance] + ) + + // Reset filter state + const reset = useCallback(() => { + rayFilterRef.current.reset() + wasActiveRef.current = false + }, []) + + return { processLandmarks, reset } +} + +/** + * Ray-Sphere Intersection + * + * Tests if a ray intersects a sphere and returns hit info. + * Used for detecting when the laser points at a node. + */ +export function rayIntersectSphere( + ray: PointerRay, + sphereCenter: Vec3, + sphereRadius: number +): { hit: boolean; distance: number; point: Vec3 } | null { + const oc = sub(ray.origin, sphereCenter) + + const a = ray.direction.x * ray.direction.x + + ray.direction.y * ray.direction.y + + ray.direction.z * ray.direction.z + const b = 2 * (oc.x * ray.direction.x + oc.y * ray.direction.y + oc.z * ray.direction.z) + const c = oc.x * oc.x + oc.y * oc.y + oc.z * oc.z - sphereRadius * sphereRadius + + const discriminant = b * b - 4 * a * c + + if (discriminant < 0) return null + + // Find nearest intersection + const t = (-b - Math.sqrt(discriminant)) / (2 * a) + + if (t < 0) return null + + const point: Vec3 = { + x: ray.origin.x + ray.direction.x * t, + y: ray.origin.y + ray.direction.y * t, + z: ray.origin.z + ray.direction.z * t, + } + + return { hit: true, distance: t, point } +} + +/** + * Test ray against multiple nodes and return closest hit + */ +export interface NodeSphere { + id: string + x: number + y: number + z: number + radius: number +} + +export function findNodeHit( + ray: PointerRay, + nodes: NodeSphere[], + maxDistance: number = 1000 +): NodeHit | null { + let closestHit: NodeHit | null = null + + for (const node of nodes) { + const result = rayIntersectSphere( + ray, + { x: node.x, y: node.y, z: node.z }, + node.radius + ) + + if (result && result.distance < maxDistance) { + if (!closestHit || result.distance < closestHit.distance) { + closestHit = { + nodeId: node.id, + distance: result.distance, + point: result.point, + } + } + } + } + + return closestHit +} + +export default useStablePointerRay diff --git a/packages/graph-viewer/src/lib/OneEuroFilter.ts b/packages/graph-viewer/src/lib/OneEuroFilter.ts new file mode 100644 index 0000000..712e2ff --- /dev/null +++ b/packages/graph-viewer/src/lib/OneEuroFilter.ts @@ -0,0 +1,179 @@ +/** + * One Euro Filter - Velocity-Adaptive Low-Pass Filter + * + * The gold standard for jitter reduction in tracking systems. + * Used by Meta Quest, Apple Vision Pro, and most AR/VR tracking systems. + * + * Key insight: Use low smoothing when moving fast (responsive), + * high smoothing when moving slow (stable/jitter-free). + * + * Paper: "1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in Interactive Systems" + * Géry Casiez, Nicolas Roussel, Daniel Vogel - CHI 2012 + */ + +export interface OneEuroFilterConfig { + /** Minimum cutoff frequency in Hz. Lower = more smoothing when slow. Default: 1.0 */ + minCutoff: number + /** Speed coefficient. Higher = more responsive when moving fast. Default: 0.007 */ + beta: number + /** Derivative cutoff frequency in Hz. Default: 1.0 */ + dCutoff: number +} + +const DEFAULT_CONFIG: OneEuroFilterConfig = { + minCutoff: 1.0, + beta: 0.007, + dCutoff: 1.0, +} + +export class OneEuroFilter { + private config: OneEuroFilterConfig + private xPrev: number | null = null + private dxPrev: number = 0 + private tPrev: number | null = null + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * Filter a single value + * @param x - Current value + * @param t - Current timestamp in seconds + * @returns Filtered value + */ + filter(x: number, t: number): number { + if (this.xPrev === null || this.tPrev === null) { + this.xPrev = x + this.dxPrev = 0 + this.tPrev = t + return x + } + + const dt = t - this.tPrev + if (dt <= 0) return this.xPrev + + // Estimate derivative (velocity) + const dx = (x - this.xPrev) / dt + + // Filter the derivative + const alphaDx = this.computeAlpha(this.config.dCutoff, dt) + const dxFiltered = this.lowPass(dx, this.dxPrev, alphaDx) + this.dxPrev = dxFiltered + + // Compute adaptive cutoff based on velocity + // Fast movement = high cutoff = responsive + // Slow movement = low cutoff = stable + const cutoff = this.config.minCutoff + this.config.beta * Math.abs(dxFiltered) + + // Filter the position with adaptive cutoff + const alpha = this.computeAlpha(cutoff, dt) + const xFiltered = this.lowPass(x, this.xPrev, alpha) + + this.xPrev = xFiltered + this.tPrev = t + + return xFiltered + } + + /** + * Reset the filter state + */ + reset(): void { + this.xPrev = null + this.dxPrev = 0 + this.tPrev = null + } + + private computeAlpha(cutoff: number, dt: number): number { + const tau = 1.0 / (2 * Math.PI * cutoff) + return 1.0 / (1.0 + tau / dt) + } + + private lowPass(x: number, xPrev: number, alpha: number): number { + return xPrev + alpha * (x - xPrev) + } +} + +/** + * 3D Point filter - applies One Euro Filter to each component + */ +export class OneEuroFilter3D { + private filterX: OneEuroFilter + private filterY: OneEuroFilter + private filterZ: OneEuroFilter + + constructor(config: Partial = {}) { + this.filterX = new OneEuroFilter(config) + this.filterY = new OneEuroFilter(config) + this.filterZ = new OneEuroFilter(config) + } + + filter(point: { x: number; y: number; z: number }, t: number): { x: number; y: number; z: number } { + return { + x: this.filterX.filter(point.x, t), + y: this.filterY.filter(point.y, t), + z: this.filterZ.filter(point.z, t), + } + } + + reset(): void { + this.filterX.reset() + this.filterY.reset() + this.filterZ.reset() + } +} + +/** + * Pointer Ray filter - stabilizes both origin and direction + */ +export interface PointerRay { + origin: { x: number; y: number; z: number } + direction: { x: number; y: number; z: number } +} + +export class PointerRayFilter { + private originFilter: OneEuroFilter3D + private directionFilter: OneEuroFilter3D + + constructor(config: Partial = {}) { + // Origin needs more stability (user doesn't consciously control it) + this.originFilter = new OneEuroFilter3D({ + minCutoff: 0.5, + beta: 0.3, + dCutoff: 1.0, + ...config, + }) + + // Direction can be more responsive (user actively aims) + this.directionFilter = new OneEuroFilter3D({ + minCutoff: 1.5, + beta: 0.8, + dCutoff: 1.0, + ...config, + }) + } + + filter(ray: PointerRay, t: number): PointerRay { + const origin = this.originFilter.filter(ray.origin, t) + const rawDirection = this.directionFilter.filter(ray.direction, t) + + // Re-normalize direction after filtering + const len = Math.sqrt( + rawDirection.x * rawDirection.x + + rawDirection.y * rawDirection.y + + rawDirection.z * rawDirection.z + ) + + const direction = len > 0 + ? { x: rawDirection.x / len, y: rawDirection.y / len, z: rawDirection.z / len } + : { x: 0, y: 0, z: -1 } + + return { origin, direction } + } + + reset(): void { + this.originFilter.reset() + this.directionFilter.reset() + } +} From 204342cc420071567cf5a8204c99f775298de1a7 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 14:30:52 +0100 Subject: [PATCH 20/47] feat: Add hand tracking server and improve gesture logic Introduces a new WebSocket server for iPhone hand tracking with web visualization in packages/graph-viewer/scripts. Refines gesture and pointer logic in GraphCanvas, useHandInteraction, and related components for more accurate hand-based interactions. Updates AGENTS.md with a task completion checklist. Removes unused code and types for clarity. --- AGENTS.md | 13 + .../scripts/hand-tracking-server.js | 345 ++++++++++++++++++ .../graph-viewer/scripts/package-lock.json | 36 ++ packages/graph-viewer/scripts/package.json | 12 + .../src/components/ExpandedNodeView.tsx | 5 +- .../src/components/GraphCanvas.tsx | 20 +- .../src/components/Hand2DOverlay.tsx | 4 +- .../src/components/LaserPointer.tsx | 2 +- .../src/hooks/useHandInteraction.ts | 9 +- .../src/hooks/useStablePointerRay.ts | 24 +- 10 files changed, 421 insertions(+), 49 deletions(-) create mode 100644 packages/graph-viewer/scripts/hand-tracking-server.js create mode 100644 packages/graph-viewer/scripts/package-lock.json create mode 100644 packages/graph-viewer/scripts/package.json diff --git a/AGENTS.md b/AGENTS.md index 0bf44d4..c0e33af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,5 +38,18 @@ - Never commit secrets. Configure via env vars: `AUTOMEM_API_TOKEN`, `ADMIN_API_TOKEN`, `OPENAI_API_KEY`, `FALKORDB_PASSWORD`, `QDRANT_API_KEY`. - Local dev uses Docker defaults; see `docs/ENVIRONMENT_VARIABLES.md` and `docker-compose.yml` for ports and credentials. +## Task Completion Checklist +**CRITICAL**: Before declaring any coding task complete, ALWAYS: +1. **Run the build**: `make build` (Python) or `npm run build` (graph-viewer) +2. **Run lints**: `make lint` (Python) or `npm run tsc` (TypeScript) +3. **Run tests** (if applicable): `make test` or `npm test` +4. **If any fail**: Iterate and fix until all pass +5. **Never commit or deploy** code that doesn't build + +For graph-viewer specifically: +```bash +cd packages/graph-viewer && npm run build +``` + ## Agent Memory Protocol Follow rules in `.cursor/rules/automem.mdc` for memory operations. diff --git a/packages/graph-viewer/scripts/hand-tracking-server.js b/packages/graph-viewer/scripts/hand-tracking-server.js new file mode 100644 index 0000000..4999fd3 --- /dev/null +++ b/packages/graph-viewer/scripts/hand-tracking-server.js @@ -0,0 +1,345 @@ +#!/usr/bin/env node +/** + * Hand Tracking WebSocket Server + * + * Receives hand landmark data from iPhone and: + * 1. Logs it to console with visualization + * 2. Forwards to any connected web clients + * + * Usage: + * node hand-tracking-server.js + * # Then connect iPhone app to ws://:8765 + * # Open http://localhost:8766 for web visualization + */ + +const { WebSocketServer, WebSocket } = require('ws'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PHONE_PORT = 8765; // iPhone connects here +const WEB_PORT = 8766; // Web visualization + +// Store latest hand data +let latestHandData = null; +let webClients = new Set(); + +// ============ iPhone WebSocket Server ============ + +const phoneServer = new WebSocketServer({ port: PHONE_PORT }); + +console.log(`📱 iPhone WebSocket server listening on ws://0.0.0.0:${PHONE_PORT}`); +console.log(` Connect your iPhone app to: ws://:${PHONE_PORT}`); + +phoneServer.on('connection', (ws, req) => { + console.log(`\n✅ iPhone connected from ${req.socket.remoteAddress}`); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + latestHandData = message; + + // Log hand detection + if (message.hands && message.hands.length > 0) { + const hand = message.hands[0]; + const wrist = hand.landmarks['VNHLKJWRIST']; + const indexTip = hand.landmarks['VNHLKJINDEXTIP']; + const hasDepth = hand.hasLiDARDepth; + + // Simple ASCII visualization + process.stdout.write('\r'); + process.stdout.write(`🖐️ Hands: ${message.hands.length} | `); + if (wrist) { + process.stdout.write(`Wrist: (${wrist.x.toFixed(2)}, ${wrist.y.toFixed(2)}`); + if (hasDepth && wrist.z !== 0) { + process.stdout.write(`, ${wrist.z.toFixed(2)}m`); + } + process.stdout.write(') | '); + } + if (indexTip) { + process.stdout.write(`Index: (${indexTip.x.toFixed(2)}, ${indexTip.y.toFixed(2)}`); + if (hasDepth && indexTip.z !== 0) { + process.stdout.write(`, ${indexTip.z.toFixed(2)}m`); + } + process.stdout.write(')'); + } + process.stdout.write(` [LiDAR: ${hasDepth ? '✓' : '✗'}]`); + process.stdout.write(' '); // Clear any trailing chars + } + + // Forward to web clients + const jsonStr = JSON.stringify(message); + for (const client of webClients) { + if (client.readyState === WebSocket.OPEN) { + client.send(jsonStr); + } + } + + } catch (e) { + console.error('Parse error:', e.message); + } + }); + + ws.on('close', () => { + console.log('\n📱 iPhone disconnected'); + }); + + ws.on('error', (err) => { + console.error('iPhone WebSocket error:', err.message); + }); +}); + +// ============ Web Visualization Server ============ + +const webHtml = ` + + + Hand Tracking Test + + + +

🖐️ Hand Tracking Test

+ +
+
+
Status
+
Connecting...
+
+
+
Hands
+
0
+
+
+
LiDAR
+
-
+
+
+
FPS
+
0
+
+
+ + + +
+ iPhone should connect to: ws://<your-mac-ip>:8765 +
+ + + +`; + +// HTTP + WebSocket server for web visualization +const httpServer = http.createServer((req, res) => { + if (req.url === '/' || req.url === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(webHtml); + } else { + res.writeHead(404); + res.end('Not found'); + } +}); + +const webWss = new WebSocketServer({ server: httpServer, path: '/ws' }); + +webWss.on('connection', (ws) => { + console.log('🌐 Web client connected'); + webClients.add(ws); + + ws.on('close', () => { + console.log('🌐 Web client disconnected'); + webClients.delete(ws); + }); +}); + +httpServer.listen(WEB_PORT, () => { + console.log(`\n🌐 Web visualization at http://localhost:${WEB_PORT}`); +}); + +// Get local IP for convenience +const { networkInterfaces } = require('os'); +const nets = networkInterfaces(); +console.log('\n📡 Your Mac IP addresses:'); +for (const name of Object.keys(nets)) { + for (const net of nets[name]) { + if (net.family === 'IPv4' && !net.internal) { + console.log(` ${name}: ${net.address}`); + } + } +} +console.log('\n'); diff --git a/packages/graph-viewer/scripts/package-lock.json b/packages/graph-viewer/scripts/package-lock.json new file mode 100644 index 0000000..7d1cc34 --- /dev/null +++ b/packages/graph-viewer/scripts/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "hand-tracking-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hand-tracking-server", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/packages/graph-viewer/scripts/package.json b/packages/graph-viewer/scripts/package.json new file mode 100644 index 0000000..25ab2d5 --- /dev/null +++ b/packages/graph-viewer/scripts/package.json @@ -0,0 +1,12 @@ +{ + "name": "hand-tracking-server", + "version": "1.0.0", + "description": "WebSocket server for iPhone hand tracking", + "main": "hand-tracking-server.js", + "scripts": { + "start": "node hand-tracking-server.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/packages/graph-viewer/src/components/ExpandedNodeView.tsx b/packages/graph-viewer/src/components/ExpandedNodeView.tsx index c06bda1..c762868 100644 --- a/packages/graph-viewer/src/components/ExpandedNodeView.tsx +++ b/packages/graph-viewer/src/components/ExpandedNodeView.tsx @@ -16,10 +16,10 @@ */ import { useRef, useMemo, useEffect, useState } from 'react' -import { useFrame, useThree } from '@react-three/fiber' +import { useFrame } from '@react-three/fiber' import { Text, Billboard, Html } from '@react-three/drei' import * as THREE from 'three' -import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' +import type { GraphEdge, SimulationNode } from '../lib/types' // Animation timing const EXPAND_DURATION = 0.6 // seconds @@ -62,7 +62,6 @@ export function ExpandedNodeView({ const groupRef = useRef(null) const rippleRef = useRef(null) const glowRef = useRef(null) - const { camera } = useThree() // Animation state const [animationProgress, setAnimationProgress] = useState(0) diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index b118377..4e1aa4c 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -29,7 +29,6 @@ import { LaserPointer } from './LaserPointer' import { ExpandedNodeView } from './ExpandedNodeView' import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' -import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' // Performance constants const SPHERE_SEGMENTS = 12 // Reduced from 32 - good enough for small spheres @@ -40,9 +39,7 @@ const MAX_VISIBLE_LABELS = 10 // Maximum labels to show at once (for LOD) const GESTURE_SMOOTHING = 0.15 // Lower = smoother but laggier (0.1-0.3 recommended) const GESTURE_DEADZONE = 0.005 // Ignore tiny movements const MAX_TRANSLATE_SPEED = 3 // Cap cloud translation per frame -const MAX_ROTATE_SPEED = 0.08 // Cap rotation rate per frame (radians) const RECENTER_STRENGTH = 0.01 // How strongly to pull cloud back to center -const PULL_SENSITIVITY = 150 // How much Z translation per unit of depth change interface GraphCanvasProps { nodes: GraphNode[] @@ -223,15 +220,6 @@ function Scene({ onNodeSelect(null) }, [onNodeSelect]) - // Track previous pinch state for delta calculations (per hand) - const prevPinchStateRef = useRef<{ - left: { origin: { x: number; y: number; z: number }; strength: number } | null - right: { origin: { x: number; y: number; z: number }; strength: number } | null - }>({ - left: null, - right: null, - }) - // Smoothed gesture values (to prevent sudden movements) const smoothedGestureRef = useRef({ translateZ: 0, @@ -351,9 +339,9 @@ function Scene({ {gestureControlEnabled && interactionState.leftRay && ( (interactionState.leftRay.confidence ?? 0) - ? null - : interactionState.hoveredNode} + hit={(interactionState.leftRay.confidence ?? 0) >= (interactionState.rightRay?.confidence ?? 0) + ? interactionState.hoveredNode + : null} color="#4ecdc4" showArmModel={false} /> @@ -361,7 +349,7 @@ function Scene({ {gestureControlEnabled && interactionState.rightRay && ( = (interactionState.leftRay?.confidence ?? 0) + hit={(interactionState.rightRay.confidence ?? 0) > (interactionState.leftRay?.confidence ?? 0) ? interactionState.hoveredNode : null} color="#f72585" diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index b1e7157..0502a44 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -10,7 +10,7 @@ * - Support for two-hand manipulation */ -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useRef } from 'react' import type { GestureState, PinchRay } from '../hooks/useHandGestures' import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' @@ -702,7 +702,7 @@ function LaserBeam({ ray, stableRay, color, isGripped, hasHit = false }: LaserBe cy={originY} r={glowRadius * 2} fill="none" - stroke={gripColor} + stroke={activeColor} strokeWidth={0.3} opacity={0.7} className="animate-ping" diff --git a/packages/graph-viewer/src/components/LaserPointer.tsx b/packages/graph-viewer/src/components/LaserPointer.tsx index 3152002..c79c209 100644 --- a/packages/graph-viewer/src/components/LaserPointer.tsx +++ b/packages/graph-viewer/src/components/LaserPointer.tsx @@ -13,7 +13,7 @@ import { useRef, useMemo } from 'react' import { useFrame } from '@react-three/fiber' import { Line } from '@react-three/drei' import * as THREE from 'three' -import type { StableRay, NodeHit, Vec3 } from '../hooks/useStablePointerRay' +import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' interface LaserPointerProps { ray: StableRay diff --git a/packages/graph-viewer/src/hooks/useHandInteraction.ts b/packages/graph-viewer/src/hooks/useHandInteraction.ts index d5f3100..c64ee5d 100644 --- a/packages/graph-viewer/src/hooks/useHandInteraction.ts +++ b/packages/graph-viewer/src/hooks/useHandInteraction.ts @@ -13,9 +13,9 @@ * This is the main entry point for hand-based graph interaction. */ -import { useRef, useCallback, useEffect, useState } from 'react' +import { useRef, useCallback, useState } from 'react' import { useStablePointerRay, findNodeHit, type StableRay, type NodeHit, type NodeSphere } from './useStablePointerRay' -import type { GestureState, HandLandmarks } from './useHandGestures' +import type { GestureState } from './useHandGestures' import type { SimulationNode } from '../lib/types' export interface InteractionState { @@ -107,7 +107,7 @@ export function useHandInteraction({ nodes, onNodeSelect, onNodeHover }: UseHand : null // Determine primary ray (prefer right hand) - const primaryRay = rightRay?.confidence > (leftRay?.confidence ?? 0) ? rightRay : leftRay + const primaryRay = (rightRay?.confidence ?? 0) > (leftRay?.confidence ?? 0) ? rightRay : leftRay // Find node hit let hoveredNode: NodeHit | null = null @@ -146,7 +146,8 @@ export function useHandInteraction({ nodes, onNodeSelect, onNodeHover }: UseHand // Calculate Z drag from hand movement const currentZ = primaryRay.origin.z - const prevZ = primaryRay === leftRay ? prev.leftZ : prev.rightZ + const isLeftPrimary = leftRay && primaryRay.pinchPoint.x === leftRay.pinchPoint.x + const prevZ = isLeftPrimary ? prev.leftZ : prev.rightZ if (prevZ !== null) { // Negative Z movement (hand toward camera) = push node away diff --git a/packages/graph-viewer/src/hooks/useStablePointerRay.ts b/packages/graph-viewer/src/hooks/useStablePointerRay.ts index c2b8e7c..90054a7 100644 --- a/packages/graph-viewer/src/hooks/useStablePointerRay.ts +++ b/packages/graph-viewer/src/hooks/useStablePointerRay.ts @@ -12,19 +12,16 @@ * By estimating the elbow and placing the pivot further back, we reduce jitter. */ -import { useRef, useCallback, useMemo } from 'react' +import { useRef, useCallback } from 'react' import { PointerRayFilter, type PointerRay } from '../lib/OneEuroFilter' import type { NormalizedLandmarkList } from '@mediapipe/hands' // MediaPipe landmark indices const WRIST = 0 const THUMB_TIP = 4 -const THUMB_CMC = 1 // Base of thumb const INDEX_TIP = 8 const INDEX_MCP = 5 // Knuckle const MIDDLE_MCP = 9 -const RING_MCP = 13 -const PINKY_MCP = 17 export interface Vec3 { x: number @@ -76,10 +73,6 @@ function sub(a: Vec3, b: Vec3): Vec3 { return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z } } -function add(a: Vec3, b: Vec3): Vec3 { - return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z } -} - function scale(v: Vec3, s: number): Vec3 { return { x: v.x * s, y: v.y * s, z: v.z * s } } @@ -93,13 +86,6 @@ function normalize(v: Vec3): Vec3 { return len > 0 ? scale(v, 1 / len) : { x: 0, y: 0, z: -1 } } -function lerp3(a: Vec3, b: Vec3, t: number): Vec3 { - return { - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - z: a.z + (b.z - a.z) * t, - } -} function distance(a: Vec3, b: Vec3): number { return length(sub(a, b)) @@ -141,14 +127,6 @@ function estimateArmPose(landmarks: NormalizedLandmarkList, handedness: 'left' | z: ((thumbTip.z || 0) + (indexTip.z || 0)) / 2, } - // Calculate palm orientation from MCP joints - const indexMcp = landmarks[INDEX_MCP] - const pinkyMcp = landmarks[PINKY_MCP] - const palmWidth = distance( - { x: indexMcp.x, y: indexMcp.y, z: indexMcp.z || 0 }, - { x: pinkyMcp.x, y: pinkyMcp.y, z: pinkyMcp.z || 0 } - ) - // Estimate shoulder position - fixed relative to screen // Shoulder is off-screen, on the same side as the hand const isRight = handedness === 'right' From e69e0af87a6f807eb17c1bba4b74c0a7ff68564a Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Dec 2025 17:40:49 +0100 Subject: [PATCH 21/47] feat: iPhone hand tracking with adaptive zoom + LiDAR depth --- .../src/components/GraphCanvas.tsx | 42 ++- .../src/hooks/useIPhoneHandTracking.ts | 351 ++++++++++++++++++ 2 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 4e1aa4c..5f12e18 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -24,12 +24,32 @@ import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing' import * as THREE from 'three' import { useForceLayout } from '../hooks/useForceLayout' import { useHandGestures, GestureState } from '../hooks/useHandGestures' +import { useIPhoneHandTracking } from '../hooks/useIPhoneHandTracking' import { useHandInteraction } from '../hooks/useHandInteraction' import { LaserPointer } from './LaserPointer' import { ExpandedNodeView } from './ExpandedNodeView' import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' +// Check if we should use iPhone tracking (based on URL param or env) +function useTrackingSource() { + const [source, setSource] = useState<'mediapipe' | 'iphone'>('mediapipe') + const [iphoneUrl, setIphoneUrl] = useState('ws://localhost:8765') + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get('iphone') === 'true') { + setSource('iphone') + } + const url = params.get('iphone_url') + if (url) { + setIphoneUrl(url) + } + }, []) + + return { source, iphoneUrl, setSource } +} + // Performance constants const SPHERE_SEGMENTS = 12 // Reduced from 32 - good enough for small spheres const LABEL_DISTANCE_THRESHOLD = 80 // Only show labels for nodes within this distance @@ -66,12 +86,26 @@ export function GraphCanvas({ onGestureStateChange, performanceMode = false, }: GraphCanvasProps) { - // Hand gesture tracking - const { gestureState, isEnabled: gesturesActive } = useHandGestures({ - enabled: gestureControlEnabled, - onGestureChange: onGestureStateChange, + // Determine tracking source + const { source, iphoneUrl } = useTrackingSource() + + // MediaPipe hand tracking (webcam) + const { gestureState: mediapipeState, isEnabled: mediapipeActive } = useHandGestures({ + enabled: gestureControlEnabled && source === 'mediapipe', + onGestureChange: source === 'mediapipe' ? onGestureStateChange : undefined, }) + // iPhone hand tracking (WebSocket) + const { gestureState: iphoneState, isConnected: iphoneConnected } = useIPhoneHandTracking({ + enabled: gestureControlEnabled && source === 'iphone', + serverUrl: iphoneUrl, + onGestureChange: source === 'iphone' ? onGestureStateChange : undefined, + }) + + // Use whichever source is active + const gestureState = source === 'iphone' ? iphoneState : mediapipeState + const gesturesActive = source === 'iphone' ? iphoneConnected : mediapipeActive + return ( = { + 'VNHLKJWRIST': 0, + 'VNHLKJTHUMBCMC': 1, + 'VNHLKJTHUMBMP': 2, + 'VNHLKJTHUMBIP': 3, + 'VNHLKJTHUMBTIP': 4, + 'VNHLKJINDEXMCP': 5, + 'VNHLKJINDEXPIP': 6, + 'VNHLKJINDEXDIP': 7, + 'VNHLKJINDEXTIP': 8, + 'VNHLKJMIDDLEMCP': 9, + 'VNHLKJMIDDLEPIP': 10, + 'VNHLKJMIDDLEDIP': 11, + 'VNHLKJMIDDLETIP': 12, + 'VNHLKJRINGMCP': 13, + 'VNHLKJRINGPIP': 14, + 'VNHLKJRINGDIP': 15, + 'VNHLKJRINGTIP': 16, + 'VNHLKJLITTLEMCP': 17, + 'VNHLKJLITTLEPIP': 18, + 'VNHLKJLITTLEDIP': 19, + 'VNHLKJLITTLETIP': 20, +} + +interface IPhoneLandmark { + x: number + y: number + z: number +} + +interface IPhoneHandPose { + handedness: 'left' | 'right' + landmarks: Record + confidence: number + timestamp: number + hasLiDARDepth: boolean +} + +interface IPhoneMessage { + type: string + hands: IPhoneHandPose[] + frameTimestamp: number +} + +interface UseIPhoneHandTrackingOptions { + /** WebSocket URL to connect to (e.g., ws://192.168.1.100:8765) */ + serverUrl?: string + /** Enable/disable the connection */ + enabled?: boolean + /** Callback when gesture state updates */ + onGestureChange?: (state: GestureState) => void +} + +// Smoothing for stability +const SMOOTHING = 0.3 + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +function distance3D(a: IPhoneLandmark, b: IPhoneLandmark): number { + const dx = a.x - b.x + const dy = a.y - b.y + const dz = a.z - b.z + return Math.sqrt(dx * dx + dy * dy + dz * dz) +} + +// Calculate pinch strength from iPhone landmarks +function calculatePinchStrength(landmarks: Record): number { + const thumbTip = landmarks['VNHLKJTHUMBTIP'] + const indexTip = landmarks['VNHLKJINDEXTIP'] + if (!thumbTip || !indexTip) return 0 + + const dist = distance3D(thumbTip, indexTip) + // iPhone normalized coords: ~0.02 pinched, ~0.15 open + return Math.max(0, Math.min(1, 1 - (dist - 0.02) / 0.13)) +} + +// Calculate pinch ray from iPhone landmarks (with REAL depth!) +function calculatePinchRay(landmarks: Record, hasLiDAR: boolean): PinchRay { + const thumbTip = landmarks['VNHLKJTHUMBTIP'] + const indexTip = landmarks['VNHLKJINDEXTIP'] + const wrist = landmarks['VNHLKJWRIST'] + + if (!thumbTip || !indexTip || !wrist) { + return { origin: { x: 0.5, y: 0.5, z: 0 }, direction: { x: 0, y: 0, z: -1 }, isValid: false, strength: 0 } + } + + // Origin: midpoint between thumb and index tips + const origin = { + x: (thumbTip.x + indexTip.x) / 2, + y: (thumbTip.y + indexTip.y) / 2, + // Use REAL depth from LiDAR if available! + z: hasLiDAR ? (thumbTip.z + indexTip.z) / 2 : 0, + } + + // Direction: from wrist through pinch point + const rawDir = { + x: origin.x - wrist.x, + y: origin.y - wrist.y, + z: hasLiDAR ? (origin.z - wrist.z) : -0.5, // Default forward if no LiDAR + } + + const length = Math.sqrt(rawDir.x * rawDir.x + rawDir.y * rawDir.y + rawDir.z * rawDir.z) + const direction = length > 0 + ? { x: rawDir.x / length, y: rawDir.y / length, z: rawDir.z / length } + : { x: 0, y: 0, z: -1 } + + const strength = calculatePinchStrength(landmarks) + const isValid = strength > 0.5 + + return { origin, direction, isValid, strength } +} + +// Convert iPhone landmarks to MediaPipe-compatible format +function convertToMediaPipeLandmarks(landmarks: Record): NormalizedLandmarkList { + const result: NormalizedLandmarkList = [] + + // Initialize all 21 landmarks with defaults + for (let i = 0; i < 21; i++) { + result.push({ x: 0.5, y: 0.5, z: 0, visibility: 0 }) + } + + // Map iPhone landmarks to MediaPipe indices + for (const [name, idx] of Object.entries(LANDMARK_MAP)) { + const lm = landmarks[name] + if (lm) { + result[idx] = { + x: lm.x, + y: lm.y, + z: lm.z, + visibility: 1, + } + } + } + + return result +} + +const DEFAULT_STATE: GestureState = { + isTracking: false, + handsDetected: 0, + leftHand: null, + rightHand: null, + twoHandDistance: 0.5, + twoHandRotation: 0, + twoHandCenter: { x: 0.5, y: 0.5 }, + pointingHand: null, + pointDirection: null, + pinchStrength: 0, + grabStrength: 0, + leftPinchRay: null, + rightPinchRay: null, + activePinchRay: null, + zoomDelta: 0, + rotateDelta: 0, + panDelta: { x: 0, y: 0 }, +} + +export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {}) { + const { + serverUrl = 'ws://localhost:8765', + enabled = true, + onGestureChange + } = options + + const [gestureState, setGestureState] = useState(DEFAULT_STATE) + const [isConnected, setIsConnected] = useState(false) + const [fps, setFps] = useState(0) + const [hasLiDAR, setHasLiDAR] = useState(false) + + const wsRef = useRef(null) + const prevStateRef = useRef(DEFAULT_STATE) + const frameCountRef = useRef(0) + const lastFpsTimeRef = useRef(Date.now()) + const reconnectTimeoutRef = useRef() + + // Process incoming hand data + const processMessage = useCallback((data: IPhoneMessage) => { + frameCountRef.current++ + const now = Date.now() + if (now - lastFpsTimeRef.current >= 1000) { + setFps(frameCountRef.current) + frameCountRef.current = 0 + lastFpsTimeRef.current = now + } + + const prev = prevStateRef.current + const newState: GestureState = { ...DEFAULT_STATE, isTracking: true } + + newState.handsDetected = data.hands.length + + if (data.hands.length > 0) { + setHasLiDAR(data.hands[0].hasLiDARDepth) + } + + // Process each hand + for (const hand of data.hands) { + const landmarks = convertToMediaPipeLandmarks(hand.landmarks) + const handData: HandLandmarks = { + landmarks, + worldLandmarks: landmarks, + handedness: hand.handedness === 'left' ? 'Left' : 'Right', + } + + const pinchRay = calculatePinchRay(hand.landmarks, hand.hasLiDARDepth) + + if (hand.handedness === 'left') { + newState.leftHand = handData + newState.leftPinchRay = pinchRay + } else { + newState.rightHand = handData + newState.rightPinchRay = pinchRay + } + } + + // Determine active pinch ray + if (newState.rightPinchRay?.isValid) { + newState.activePinchRay = newState.rightPinchRay + } else if (newState.leftPinchRay?.isValid) { + newState.activePinchRay = newState.leftPinchRay + } + + // Two-hand calculations + if (newState.leftHand && newState.rightHand) { + const leftWrist = newState.leftHand.landmarks[0] + const rightWrist = newState.rightHand.landmarks[0] + + // Distance between hands + const dx = leftWrist.x - rightWrist.x + const dy = leftWrist.y - rightWrist.y + const rawDistance = Math.sqrt(dx * dx + dy * dy) + newState.twoHandDistance = lerp(prev.twoHandDistance, rawDistance, 1 - SMOOTHING) + + // Rotation + const rawRotation = Math.atan2(rightWrist.y - leftWrist.y, rightWrist.x - leftWrist.x) + newState.twoHandRotation = lerp(prev.twoHandRotation, rawRotation, 1 - SMOOTHING) + + // Center + newState.twoHandCenter = { + x: lerp(prev.twoHandCenter.x, (leftWrist.x + rightWrist.x) / 2, 1 - SMOOTHING), + y: lerp(prev.twoHandCenter.y, (leftWrist.y + rightWrist.y) / 2, 1 - SMOOTHING), + } + + // Deltas + newState.zoomDelta = (newState.twoHandDistance - prev.twoHandDistance) * 5 + newState.rotateDelta = newState.twoHandRotation - prev.twoHandRotation + } + + // Pinch strength (smoothed) + const primaryHand = newState.rightHand || newState.leftHand + if (primaryHand) { + const strength = calculatePinchStrength( + Object.fromEntries( + Object.entries(LANDMARK_MAP).map(([name, idx]) => [ + name, + { x: primaryHand.landmarks[idx].x, y: primaryHand.landmarks[idx].y, z: primaryHand.landmarks[idx].z || 0 } + ]) + ) + ) + newState.pinchStrength = lerp(prev.pinchStrength, strength, 1 - SMOOTHING) + } + + prevStateRef.current = newState + setGestureState(newState) + onGestureChange?.(newState) + }, [onGestureChange]) + + // WebSocket connection + useEffect(() => { + if (!enabled) { + wsRef.current?.close() + setIsConnected(false) + return + } + + const connect = () => { + try { + const ws = new WebSocket(serverUrl) + wsRef.current = ws + + ws.onopen = () => { + console.log('📱 Connected to iPhone hand tracking') + setIsConnected(true) + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as IPhoneMessage + if (data.type === 'hand_tracking') { + processMessage(data) + } + } catch (e) { + console.error('Parse error:', e) + } + } + + ws.onclose = () => { + console.log('📱 Disconnected from iPhone') + setIsConnected(false) + setGestureState(DEFAULT_STATE) + + // Reconnect after delay + if (enabled) { + reconnectTimeoutRef.current = window.setTimeout(connect, 2000) + } + } + + ws.onerror = (err) => { + console.error('WebSocket error:', err) + } + } catch (e) { + console.error('Connection error:', e) + if (enabled) { + reconnectTimeoutRef.current = window.setTimeout(connect, 2000) + } + } + } + + connect() + + return () => { + clearTimeout(reconnectTimeoutRef.current) + wsRef.current?.close() + } + }, [enabled, serverUrl, processMessage]) + + return { + gestureState, + isConnected, + fps, + hasLiDAR, + isEnabled: enabled && isConnected, + } +} + +export default useIPhoneHandTracking From 803db3049c54f36c6bcef98d5e33e59ffd0f5aa2 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Fri, 12 Dec 2025 00:37:11 +0100 Subject: [PATCH 22/47] feat: Add hand lock and grab gesture controls Introduces a new hand lock and grab state machine for intentional gesture-based cloud manipulation. Adds the useHandLockAndGrab hook, HandControlOverlay UI, and integrates these into App and GraphCanvas. Updates useHandInteraction to allow disabling pinch-to-select when using grab controls, and enhances iPhone hand tracking to compute grab strength. This enables more robust and user-friendly hand gesture interactions for zooming and rotating the graph. --- packages/graph-viewer/src/App.tsx | 10 + .../src/components/GraphCanvas.tsx | 52 ++- .../src/components/HandControlOverlay.tsx | 70 ++++ .../src/hooks/useHandInteraction.ts | 13 +- .../src/hooks/useHandLockAndGrab.ts | 309 ++++++++++++++++++ .../src/hooks/useIPhoneHandTracking.ts | 40 ++- 6 files changed, 466 insertions(+), 28 deletions(-) create mode 100644 packages/graph-viewer/src/components/HandControlOverlay.tsx create mode 100644 packages/graph-viewer/src/hooks/useHandLockAndGrab.ts diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 493ed27..6224e78 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -13,6 +13,8 @@ import { TokenPrompt } from './components/TokenPrompt' import { StatsBar } from './components/StatsBar' import { GestureDebugOverlay } from './components/GestureDebugOverlay' import { Hand2DOverlay } from './components/Hand2DOverlay' +import { HandControlOverlay } from './components/HandControlOverlay' +import { useHandLockAndGrab } from './hooks/useHandLockAndGrab' import type { GraphNode, FilterState } from './lib/types' import type { GestureState } from './hooks/useHandGestures' @@ -96,6 +98,11 @@ export default function App() { setGestureState(state) }, []) + const { lock: handLock } = useHandLockAndGrab(gestureState, gestureControlEnabled) + const trackingSource = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('iphone') === 'true' + ? 'iphone' + : 'mediapipe' + const { data, isLoading, error, refetch } = useGraphSnapshot({ limit: filters.maxNodes, minImportance: filters.minImportance, @@ -266,6 +273,9 @@ export default function App() { gestureState={gestureState} visible={debugOverlayVisible && gestureControlEnabled} /> + + {/* Hand Control Overlay (lock/grab metrics) */} +
diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 5f12e18..7881a91 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -26,6 +26,7 @@ import { useForceLayout } from '../hooks/useForceLayout' import { useHandGestures, GestureState } from '../hooks/useHandGestures' import { useIPhoneHandTracking } from '../hooks/useIPhoneHandTracking' import { useHandInteraction } from '../hooks/useHandInteraction' +import { useHandLockAndGrab } from '../hooks/useHandLockAndGrab' import { LaserPointer } from './LaserPointer' import { ExpandedNodeView } from './ExpandedNodeView' import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' @@ -34,7 +35,7 @@ import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' // Check if we should use iPhone tracking (based on URL param or env) function useTrackingSource() { const [source, setSource] = useState<'mediapipe' | 'iphone'>('mediapipe') - const [iphoneUrl, setIphoneUrl] = useState('ws://localhost:8765') + const [iphoneUrl, setIphoneUrl] = useState('ws://localhost:8766/ws') useEffect(() => { const params = new URLSearchParams(window.location.search) @@ -160,6 +161,7 @@ function Scene({ // Hand interaction with stable pointer ray const { interactionState, processGestures } = useHandInteraction({ nodes: layoutNodes, + enableSelection: false, onNodeSelect: (nodeId) => { if (nodeId) { // Find the node and trigger expansion @@ -193,6 +195,9 @@ function Scene({ } }, [gestureControlEnabled, gestureState, processGestures]) + // New UI: open-palm acquire/lock + fist grab controls (single-hand for now) + const { lock: handLock, deltas: grabDeltas } = useHandLockAndGrab(gestureState, gestureControlEnabled) + // Create node lookup for edges const nodeById = useMemo( () => new Map(layoutNodes.map((n) => [n.id, n])), @@ -276,21 +281,38 @@ function Scene({ // Use the new interaction state for cloud manipulation const { rotationDelta, zoomDelta, dragDeltaZ, isDragging } = interactionState - // Apply zoom (two-hand spread/pinch) - if (Math.abs(zoomDelta) > GESTURE_DEADZONE) { - group.position.z += zoomDelta * 0.5 - } + const usingGrabControls = handLock.mode === 'locked' && handLock.grabbed - // Apply rotation (two-hand rotation) - if (Math.abs(rotationDelta.x) > GESTURE_DEADZONE) { - group.rotation.z += rotationDelta.x - } + if (usingGrabControls) { + // Exponential zoom velocity already computed; smooth + clamp + smoothed.translateZ += (grabDeltas.zoom - smoothed.translateZ) * GESTURE_SMOOTHING + const zVel = clamp(smoothed.translateZ, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) + if (Math.abs(zVel) > GESTURE_DEADZONE) { + group.position.z += zVel + } - // Apply Z drag (single hand push/pull when not selecting a node) - if (!isDragging && Math.abs(dragDeltaZ) > GESTURE_DEADZONE) { - smoothed.translateZ += (dragDeltaZ - smoothed.translateZ) * GESTURE_SMOOTHING - const clamped = clamp(smoothed.translateZ, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) - group.position.z += clamped + // Rotation (pitch/yaw) + const rx = clamp(grabDeltas.rotateX, -0.08, 0.08) + const ry = clamp(grabDeltas.rotateY, -0.08, 0.08) + if (Math.abs(rx) > GESTURE_DEADZONE) group.rotation.x += rx + if (Math.abs(ry) > GESTURE_DEADZONE) group.rotation.y += ry + } else { + // Apply zoom (two-hand spread/pinch) + if (Math.abs(zoomDelta) > GESTURE_DEADZONE) { + group.position.z += zoomDelta * 0.5 + } + + // Apply rotation (two-hand rotation) + if (Math.abs(rotationDelta.x) > GESTURE_DEADZONE) { + group.rotation.z += rotationDelta.x + } + + // Apply Z drag (single hand push/pull when not selecting a node) + if (!isDragging && Math.abs(dragDeltaZ) > GESTURE_DEADZONE) { + smoothed.translateZ += (dragDeltaZ - smoothed.translateZ) * GESTURE_SMOOTHING + const clamped = clamp(smoothed.translateZ, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) + group.position.z += clamped + } } // Decay smoothed values @@ -304,7 +326,7 @@ function Scene({ group.position.z *= (1 - RECENTER_STRENGTH) group.rotation.x *= (1 - RECENTER_STRENGTH) group.rotation.y *= (1 - RECENTER_STRENGTH) - }, [gestureControlEnabled, gestureState, interactionState]) + }, [gestureControlEnabled, gestureState, interactionState, handLock, grabDeltas]) return ( <> diff --git a/packages/graph-viewer/src/components/HandControlOverlay.tsx b/packages/graph-viewer/src/components/HandControlOverlay.tsx new file mode 100644 index 0000000..474078b --- /dev/null +++ b/packages/graph-viewer/src/components/HandControlOverlay.tsx @@ -0,0 +1,70 @@ +import type { HandLockState } from '../hooks/useHandLockAndGrab' + +interface HandControlOverlayProps { + enabled: boolean + lock: HandLockState + source: 'mediapipe' | 'iphone' +} + +export function HandControlOverlay({ enabled, lock, source }: HandControlOverlayProps) { + if (!enabled) return null + + const badge = + lock.mode === 'locked' + ? lock.grabbed + ? { text: 'GRABBED', color: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30' } + : { text: 'LOCKED', color: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30' } + : lock.mode === 'candidate' + ? { text: `ACQUIRING (${lock.frames})`, color: 'bg-yellow-500/20 text-yellow-200 border-yellow-400/30' } + : { text: 'IDLE', color: 'bg-slate-500/20 text-slate-200 border-slate-400/30' } + + const m = lock.mode === 'idle' ? lock.metrics : lock.metrics + + return ( +
+
+
+ Hand Control + {badge.text} +
+ +
+ Source + {source === 'iphone' ? 'iPhone (LiDAR)' : 'Webcam (MediaPipe)'} +
+ + {m && ( +
+
+ spread + {m.spread.toFixed(2)} +
+
+ palm + {m.palmFacing.toFixed(2)} +
+
+ grab + {m.grab.toFixed(2)} +
+
+ depth + {m.depth.toFixed(3)} +
+
+ )} + +
+
+ Acquire: raise open palm + spread fingers +
+
+ Manipulate: make fist; pull/push to zoom; move to rotate +
+
+
+
+ ) +} + +export default HandControlOverlay diff --git a/packages/graph-viewer/src/hooks/useHandInteraction.ts b/packages/graph-viewer/src/hooks/useHandInteraction.ts index c64ee5d..862285e 100644 --- a/packages/graph-viewer/src/hooks/useHandInteraction.ts +++ b/packages/graph-viewer/src/hooks/useHandInteraction.ts @@ -44,6 +44,8 @@ interface UseHandInteractionOptions { onNodeSelect?: (nodeId: string | null) => void /** Callback when node hover changes */ onNodeHover?: (nodeId: string | null) => void + /** Disable pinch-to-select behavior (useful when using fist-grab controls) */ + enableSelection?: boolean } // Sensitivity settings @@ -51,7 +53,12 @@ const DRAG_Z_SENSITIVITY = 80 const ROTATION_SENSITIVITY = 2 const ZOOM_SENSITIVITY = 150 -export function useHandInteraction({ nodes, onNodeSelect, onNodeHover }: UseHandInteractionOptions) { +export function useHandInteraction({ + nodes, + onNodeSelect, + onNodeHover, + enableSelection = true, +}: UseHandInteractionOptions) { // Stable pointer ray processors for each hand const leftRayProcessor = useStablePointerRay({ handedness: 'left' }) const rightRayProcessor = useStablePointerRay({ handedness: 'right' }) @@ -134,7 +141,7 @@ export function useHandInteraction({ nodes, onNodeSelect, onNodeHover }: UseHand let isDragging = false let dragDeltaZ = 0 - if (primaryRay?.isActive) { + if (enableSelection && primaryRay?.isActive) { if (hoveredNode && !prev.selectedNodeId) { // Start selection selectedNodeId = hoveredNode.nodeId @@ -156,7 +163,7 @@ export function useHandInteraction({ nodes, onNodeSelect, onNodeHover }: UseHand } } else { // Released - clear selection - if (prev.selectedNodeId) { + if (enableSelection && prev.selectedNodeId) { selectedNodeId = null onNodeSelect?.(null) } diff --git a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts new file mode 100644 index 0000000..72517b9 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts @@ -0,0 +1,309 @@ +/** + * Hand Lock + Grab State Machine + * + * Goal: Make gestures intentional. + * - Hand is ignored until user presents an "open palm + spread fingers" pose (acquire). + * - Once acquired, we maintain a lock for a short time even through partial landmark loss. + * - In locked state, a closed fist ("grab") manipulates the cloud: + * - Pull toward body => zoom in (exponential response) + * - Push toward screen => zoom out (exponential response) + * - Move fist around screen => rotate cloud + * + * Works with either MediaPipe or iPhone-fed landmarks because it only needs GestureState. + */ + +import { useMemo, useRef } from 'react' +import type { GestureState } from './useHandGestures' + +type HandSide = 'left' | 'right' + +export interface HandLockMetrics { + /** 0..1: how open/spread the hand is */ + spread: number + /** -1..1: palm facing camera confidence-ish (1 = facing camera) */ + palmFacing: number + /** 0..1: fist/grab strength (1 = closed fist) */ + grab: number + /** depth signal (meters for iPhone LiDAR when available, otherwise MediaPipe-relative) */ + depth: number + /** 0..1 heuristic confidence */ + confidence: number +} + +export type HandLockState = + | { mode: 'idle'; metrics: HandLockMetrics | null } + | { mode: 'candidate'; metrics: HandLockMetrics; frames: number } + | { + mode: 'locked' + hand: HandSide + metrics: HandLockMetrics + lockedAtMs: number + /** pose at lock time */ + neutral: { x: number; y: number; depth: number } + /** are we currently in grab mode */ + grabbed: boolean + /** pose at grab start */ + grabAnchor?: { x: number; y: number; depth: number } + /** when we last saw a usable hand */ + lastSeenMs: number + } + +export interface CloudControlDeltas { + /** zoom velocity (positive -> zoom in, negative -> zoom out) */ + zoom: number + /** rotation deltas (radians) */ + rotateX: number + rotateY: number +} + +const DEFAULT_CONFIDENCE = 0.7 + +// Tunables (these matter a lot for UX) +const ACQUIRE_FRAMES_REQUIRED = 4 +const LOCK_PERSIST_MS = 450 + +const SPREAD_THRESHOLD = 0.65 +const PALM_FACING_THRESHOLD = 0.55 + +const GRAB_ON_THRESHOLD = 0.72 +const GRAB_OFF_THRESHOLD = 0.45 + +// Control sensitivity +const ROTATE_GAIN = 1.8 +const DEPTH_GAIN = 2.6 // exponential factor +const DEPTH_DEADZONE = 0.01 +const ROT_DEADZONE = 0.003 + +function clamp(v: number, min: number, max: number) { + return Math.max(min, Math.min(max, v)) +} + +function length2(dx: number, dy: number) { + return Math.sqrt(dx * dx + dy * dy) +} + +function safeDiv(a: number, b: number, fallback = 0) { + return b !== 0 ? a / b : fallback +} + +/** + * Compute simple metrics from landmarks (MediaPipe-style normalized 0..1) + * Works for both sources because iPhone data is mapped into GestureState landmarks. + */ +function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | null { + const handData = hand === 'right' ? state.rightHand : state.leftHand + if (!handData) return null + + const lm = handData.landmarks + // Required joints + const wrist = lm[0] + const indexMcp = lm[5] + const middleMcp = lm[9] + + // Fingertips + const indexTip = lm[8] + const middleTip = lm[12] + const ringTip = lm[16] + const pinkyTip = lm[20] + + // Spread: average fingertip distance from palm center proxy (middle MCP) + const palmCx = middleMcp.x + const palmCy = middleMcp.y + const d1 = length2(indexTip.x - palmCx, indexTip.y - palmCy) + const d2 = length2(middleTip.x - palmCx, middleTip.y - palmCy) + const d3 = length2(ringTip.x - palmCx, ringTip.y - palmCy) + const d4 = length2(pinkyTip.x - palmCx, pinkyTip.y - palmCy) + const avg = (d1 + d2 + d3 + d4) / 4 + // Normalize: typical spread-ish values ~0.08..0.22 depending on distance/FOV + const spread = clamp(safeDiv(avg - 0.06, 0.16), 0, 1) + + // Palm facing heuristic: + // In image space, if wrist is "below" MCPs, palm likely faces camera. + // (This is crude but works for the acquisition gesture.) + const palmFacing = clamp(safeDiv((wrist.y - (indexMcp.y + middleMcp.y) / 2) - 0.02, 0.12), 0, 1) * 2 - 1 + + // Grab: use state.grabStrength only if it's meaningful (computed by source); + // otherwise approximate from fingertip distances to wrist (closed fist => smaller) + const hasGrabStrength = + typeof state.grabStrength === 'number' && + state.handsDetected >= 1 && + state.grabStrength > 0 // avoid default 0 from sources that don't compute it + let grab = hasGrabStrength ? clamp(state.grabStrength, 0, 1) : 0 + if (!hasGrabStrength) { + const dw1 = length2(indexTip.x - wrist.x, indexTip.y - wrist.y) + const dw2 = length2(middleTip.x - wrist.x, middleTip.y - wrist.y) + const dw3 = length2(ringTip.x - wrist.x, ringTip.y - wrist.y) + const dw4 = length2(pinkyTip.x - wrist.x, pinkyTip.y - wrist.y) + const avgDw = (dw1 + dw2 + dw3 + dw4) / 4 + // Typical: ~0.10 fist .. ~0.25 open + grab = clamp(1 - safeDiv(avgDw - 0.10, 0.15), 0, 1) + } + + // Depth: prefer pinch ray origin z when present (iPhone LiDAR mapped into landmarks z) + const pinchRay = hand === 'right' ? state.rightPinchRay : state.leftPinchRay + const depth = (pinchRay?.origin.z ?? wrist.z ?? 0) as number + + // Confidence: use landmark visibility if present; else assume ok + const vis = (wrist as any).visibility + const confidence = typeof vis === 'number' ? clamp(vis, 0, 1) : DEFAULT_CONFIDENCE + + return { spread, palmFacing, grab, depth, confidence } +} + +function isAcquirePose(m: HandLockMetrics) { + return m.spread >= SPREAD_THRESHOLD && m.palmFacing >= PALM_FACING_THRESHOLD && m.confidence >= 0.4 +} + +function expResponse(delta: number, gain: number) { + const s = Math.sign(delta) + const a = Math.abs(delta) + return s * (Math.exp(a * gain) - 1) +} + +export function useHandLockAndGrab(state: GestureState, enabled: boolean) { + const lockRef = useRef({ mode: 'idle', metrics: null }) + + const nowMs = performance.now() + + const right = enabled ? computeMetrics(state, 'right') : null + const left = enabled ? computeMetrics(state, 'left') : null + + // For now, single-hand only: prefer right if present, else left. + const chosenHand: HandSide | null = right ? 'right' : left ? 'left' : null + const metrics = chosenHand === 'right' ? right : chosenHand === 'left' ? left : null + + const next = useMemo((): { lock: HandLockState; deltas: CloudControlDeltas } => { + if (!enabled) { + lockRef.current = { mode: 'idle', metrics: null } + return { lock: lockRef.current, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + + const prev = lockRef.current + + // No hand seen + if (!chosenHand || !metrics) { + if (prev.mode === 'locked') { + // persist lock briefly + if (nowMs - prev.lastSeenMs <= LOCK_PERSIST_MS) { + const persisted: HandLockState = { ...prev, metrics: prev.metrics } + lockRef.current = persisted + return { lock: persisted, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + } + lockRef.current = { mode: 'idle', metrics: null } + return { lock: lockRef.current, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + + // Hand seen: update FSM + if (prev.mode === 'idle') { + if (isAcquirePose(metrics)) { + const candidate: HandLockState = { mode: 'candidate', metrics, frames: 1 } + lockRef.current = candidate + return { lock: candidate, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + const idle: HandLockState = { mode: 'idle', metrics } + lockRef.current = idle + return { lock: idle, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + + if (prev.mode === 'candidate') { + if (isAcquirePose(metrics)) { + const frames = prev.frames + 1 + if (frames >= ACQUIRE_FRAMES_REQUIRED) { + // lock! + const handData = chosenHand === 'right' ? state.rightHand : state.leftHand + const wrist = handData?.landmarks[0] + const locked: HandLockState = { + mode: 'locked', + hand: chosenHand, + metrics, + lockedAtMs: nowMs, + neutral: { x: wrist?.x ?? 0.5, y: wrist?.y ?? 0.5, depth: metrics.depth }, + grabbed: false, + lastSeenMs: nowMs, + } + lockRef.current = locked + return { lock: locked, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + const candidate: HandLockState = { mode: 'candidate', metrics, frames } + lockRef.current = candidate + return { lock: candidate, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + // lost candidate + const idle: HandLockState = { mode: 'idle', metrics } + lockRef.current = idle + return { lock: idle, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + } + + // locked + if (prev.mode === 'locked') { + const handData = prev.hand === 'right' ? state.rightHand : state.leftHand + const wrist = handData?.landmarks[0] + const x = wrist?.x ?? prev.neutral.x + const y = wrist?.y ?? prev.neutral.y + + // Grab hysteresis + const grabbed = + prev.grabbed ? metrics.grab >= GRAB_OFF_THRESHOLD : metrics.grab >= GRAB_ON_THRESHOLD + + const lock: HandLockState = { + ...prev, + metrics, + grabbed, + lastSeenMs: nowMs, + } + + let deltas: CloudControlDeltas = { zoom: 0, rotateX: 0, rotateY: 0 } + + if (grabbed) { + const anchor = prev.grabAnchor ?? { x, y, depth: metrics.depth } + // On first grab frame, set anchor + if (!prev.grabbed) { + lock.grabAnchor = anchor + lockRef.current = lock + return { lock, deltas } + } + + // Depth -> zoom (exponential) + const dz = metrics.depth - anchor.depth + if (Math.abs(dz) > DEPTH_DEADZONE) { + deltas.zoom = expResponse(dz, DEPTH_GAIN) + } + + // Position -> rotation + const dx = x - anchor.x + const dy = y - anchor.y + if (Math.abs(dx) > ROT_DEADZONE || Math.abs(dy) > ROT_DEADZONE) { + deltas.rotateY = dx * ROTATE_GAIN + deltas.rotateX = -dy * ROTATE_GAIN + } + } else { + lock.grabAnchor = undefined + } + + lockRef.current = lock + return { lock, deltas } + } + + lockRef.current = { mode: 'idle', metrics } + return { lock: lockRef.current, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + }, [ + enabled, + chosenHand, + // metrics is a new object each render; depend on its fields instead + metrics?.spread, + metrics?.palmFacing, + metrics?.grab, + metrics?.depth, + metrics?.confidence, + nowMs, + state.leftHand, + state.rightHand, + state.handsDetected, + state.grabStrength, + state.leftPinchRay, + state.rightPinchRay, + ]) + + return next +} diff --git a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts index 9d6b8f8..9012c0c 100644 --- a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts +++ b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts @@ -90,6 +90,21 @@ function calculatePinchStrength(landmarks: Record): numb return Math.max(0, Math.min(1, 1 - (dist - 0.02) / 0.13)) } +// Closed fist strength from fingertip-to-wrist distances +function calculateGrabStrength(landmarks: Record): number { + const wrist = landmarks['VNHLKJWRIST'] + if (!wrist) return 0 + + const tips = ['VNHLKJTHUMBTIP', 'VNHLKJINDEXTIP', 'VNHLKJMIDDLETIP', 'VNHLKJRINGTIP', 'VNHLKJLITTLETIP'] + .map((k) => landmarks[k]) + .filter(Boolean) as IPhoneLandmark[] + if (tips.length < 3) return 0 + + const avg = tips.reduce((sum, t) => sum + distance3D(t, wrist), 0) / tips.length + // Typical range (rough): ~0.08 fist .. ~0.25 open + return Math.max(0, Math.min(1, 1 - (avg - 0.08) / 0.17)) +} + // Calculate pinch ray from iPhone landmarks (with REAL depth!) function calculatePinchRay(landmarks: Record, hasLiDAR: boolean): PinchRay { const thumbTip = landmarks['VNHLKJTHUMBTIP'] @@ -173,7 +188,9 @@ const DEFAULT_STATE: GestureState = { export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {}) { const { - serverUrl = 'ws://localhost:8765', + // Default to the local bridge's web client endpoint + // (iPhone connects to :8765; browser/web-app should connect to :8766/ws) + serverUrl = 'ws://localhost:8766/ws', enabled = true, onGestureChange } = options @@ -264,15 +281,18 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} // Pinch strength (smoothed) const primaryHand = newState.rightHand || newState.leftHand if (primaryHand) { - const strength = calculatePinchStrength( - Object.fromEntries( - Object.entries(LANDMARK_MAP).map(([name, idx]) => [ - name, - { x: primaryHand.landmarks[idx].x, y: primaryHand.landmarks[idx].y, z: primaryHand.landmarks[idx].z || 0 } - ]) - ) - ) - newState.pinchStrength = lerp(prev.pinchStrength, strength, 1 - SMOOTHING) + const reconstructed = Object.fromEntries( + Object.entries(LANDMARK_MAP).map(([name, idx]) => [ + name, + { x: primaryHand.landmarks[idx].x, y: primaryHand.landmarks[idx].y, z: primaryHand.landmarks[idx].z || 0 }, + ]) + ) as Record + + const pinch = calculatePinchStrength(reconstructed) + const grab = calculateGrabStrength(reconstructed) + + newState.pinchStrength = lerp(prev.pinchStrength, pinch, 1 - SMOOTHING) + newState.grabStrength = lerp(prev.grabStrength, grab, 1 - SMOOTHING) } prevStateRef.current = newState From aac5c814646e3396df492589778f38993e9e2ea7 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 14 Dec 2025 00:29:24 +0100 Subject: [PATCH 23/47] feat(graph-viewer): enhance API proxy configuration and gesture controls Updated Vite configuration to allow dynamic API target via environment variable, improving flexibility for local and production environments. Adjusted Hand2DOverlay to include hand lock state for visual feedback during interactions. Enhanced GraphCanvas with new aiming and pinch-click features, refining user experience for gesture-based interactions. Added support for URL parameters to override server settings for local development, streamlining testing against remote backends. --- packages/graph-viewer/src/App.tsx | 3 +- packages/graph-viewer/src/api/client.ts | 24 +- .../src/components/GraphCanvas.tsx | 232 ++++++++++++++++-- .../src/components/Hand2DOverlay.tsx | 46 +++- .../src/components/HandControlOverlay.tsx | 13 +- .../src/hooks/useHandLockAndGrab.ts | 37 ++- packages/graph-viewer/vite.config.ts | 13 +- 7 files changed, 324 insertions(+), 44 deletions(-) diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 6224e78..aa62947 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -265,7 +265,8 @@ export default function App() { {/* Gesture Debug Overlay */} diff --git a/packages/graph-viewer/src/api/client.ts b/packages/graph-viewer/src/api/client.ts index fef611c..d2681c6 100644 --- a/packages/graph-viewer/src/api/client.ts +++ b/packages/graph-viewer/src/api/client.ts @@ -20,6 +20,13 @@ function getTokenFromHash(): string | null { } function getApiBase(): string { + // Allow override via URL param for local dev against remote backend + const urlParams = new URLSearchParams(window.location.search) + const serverOverride = urlParams.get('server') + if (serverOverride) { + return serverOverride + } + if (isEmbeddedMode()) { // In embedded mode, use relative URL (same origin) return '' @@ -27,9 +34,14 @@ function getApiBase(): string { return localStorage.getItem('automem_server') || 'http://localhost:8001' } +function getTokenFromQuery(): string | null { + const urlParams = new URLSearchParams(window.location.search) + return urlParams.get('token') +} + function getToken(): string | null { - // Priority: URL hash > localStorage - return getTokenFromHash() || localStorage.getItem('automem_token') + // Priority: URL query param > URL hash > localStorage + return getTokenFromQuery() || getTokenFromHash() || localStorage.getItem('automem_token') } function getAuthHeaders(): HeadersInit { @@ -49,6 +61,14 @@ export function setServerConfig(serverUrl: string, token: string): void { } export function getServerConfig(): { serverUrl: string; token: string } | null { + // Check URL params first (for local dev against remote backend) + const urlParams = new URLSearchParams(window.location.search) + const serverOverride = urlParams.get('server') + const tokenOverride = urlParams.get('token') + if (serverOverride && tokenOverride) { + return { serverUrl: serverOverride, token: tokenOverride } + } + // In embedded mode, check for hash token if (isEmbeddedMode()) { const hashToken = getTokenFromHash() diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 7881a91..2db56c9 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -27,10 +27,10 @@ import { useHandGestures, GestureState } from '../hooks/useHandGestures' import { useIPhoneHandTracking } from '../hooks/useIPhoneHandTracking' import { useHandInteraction } from '../hooks/useHandInteraction' import { useHandLockAndGrab } from '../hooks/useHandLockAndGrab' -import { LaserPointer } from './LaserPointer' import { ExpandedNodeView } from './ExpandedNodeView' import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' +import { findNodeHit, type NodeSphere, type NodeHit } from '../hooks/useStablePointerRay' // Check if we should use iPhone tracking (based on URL param or env) function useTrackingSource() { @@ -152,13 +152,14 @@ function Scene({ const [autoRotate, setAutoRotate] = useState(false) const groupRef = useRef(null) const controlsRef = useRef(null) + const { camera } = useThree() // Expanded node state (for the bloom animation) const [expandedNodeId, setExpandedNodeId] = useState(null) const [hitPoint, setHitPoint] = useState<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }) const [isExpanding, setIsExpanding] = useState(false) - // Hand interaction with stable pointer ray + // Hand interaction (stable rays) - used for future two-hand gestures and internal metrics const { interactionState, processGestures } = useHandInteraction({ nodes: layoutNodes, enableSelection: false, @@ -182,10 +183,8 @@ function Scene({ onNodeSelect(null) } }, - onNodeHover: (nodeId) => { - const node = nodeId ? layoutNodes.find(n => n.id === nodeId) ?? null : null - onNodeHover(node) - }, + // We'll drive hover from the explicit pointing ray (below) + onNodeHover: undefined, }) // Process gestures each frame @@ -198,12 +197,169 @@ function Scene({ // New UI: open-palm acquire/lock + fist grab controls (single-hand for now) const { lock: handLock, deltas: grabDeltas } = useHandLockAndGrab(gestureState, gestureControlEnabled) + // Pointing ray + pinch-click + const [aimHit, setAimHit] = useState(null) + const [aimWorldPoint, setAimWorldPoint] = useState<{ x: number; y: number; z: number } | null>(null) + const aimRayRef = useRef<{ origin: THREE.Vector3; direction: THREE.Vector3 } | null>(null) + const pinchDownRef = useRef(false) + const pressedNodeRef = useRef(null) + + const nodeSpheres: NodeSphere[] = useMemo(() => { + return layoutNodes.map((n) => ({ + id: n.id, + x: n.x ?? 0, + y: n.y ?? 0, + z: n.z ?? 0, + radius: (n.radius ?? 1) * 1.5, + })) + }, [layoutNodes]) + + // Helper: select a node by id and animate expansion + const selectNodeById = useCallback( + (nodeId: string | null, hit?: { x: number; y: number; z: number }) => { + if (nodeId) { + const node = layoutNodes.find((n) => n.id === nodeId) ?? null + if (node) { + setExpandedNodeId(nodeId) + setHitPoint({ + x: hit?.x ?? node.x ?? 0, + y: hit?.y ?? node.y ?? 0, + z: hit?.z ?? node.z ?? 0, + }) + setIsExpanding(true) + } + onNodeSelect(node) + } else { + setExpandedNodeId(null) + setIsExpanding(false) + onNodeSelect(null) + } + }, + [layoutNodes, onNodeSelect] + ) + // Create node lookup for edges const nodeById = useMemo( () => new Map(layoutNodes.map((n) => [n.id, n])), [layoutNodes] ) + // Pointing + pinch click (Meta-style: index points, pinch clicks; fist grabs) + useEffect(() => { + if (!gestureControlEnabled) return + + if (handLock.mode !== 'locked') { + setAimHit(null) + setAimWorldPoint(null) + aimRayRef.current = null + pinchDownRef.current = false + pressedNodeRef.current = null + onNodeHover(null) + return + } + + const m = handLock.metrics + const isAimPose = !handLock.grabbed && m.point > 0.55 + if (!isAimPose) { + setAimHit(null) + setAimWorldPoint(null) + aimRayRef.current = null + pinchDownRef.current = false + pressedNodeRef.current = null + onNodeHover(null) + return + } + + const handData = handLock.hand === 'right' ? gestureState.rightHand : gestureState.leftHand + if (!handData) return + + const indexTip = handData.landmarks[8] + + // Convert to screen-space (0..1, origin bottom-left) + // Both MediaPipe and iPhone are in "camera image" coords (x left->right, y top->bottom). + // For intuitive interaction, mirror X like a selfie preview, and invert Y to bottom-left origin. + const screenX = 1 - indexTip.x + const screenY = 1 - indexTip.y + + // Build a ray from the camera through the screen point + const ndcX = screenX * 2 - 1 + const ndcY = screenY * 2 - 1 + const worldPoint = new THREE.Vector3(ndcX, ndcY, 0.5).unproject(camera) + const direction = worldPoint.sub(camera.position).normalize() + const origin = camera.position.clone() + aimRayRef.current = { origin, direction: direction.clone() } + + // Hit test against spheres in graph GROUP local coordinates + const group = groupRef.current + let hit: NodeHit | null = null + if (group) { + const inv = group.matrixWorld.clone().invert() + const localOrigin = origin.clone().applyMatrix4(inv) + const localDir = direction.clone().transformDirection(inv) + hit = findNodeHit( + { + origin: { x: localOrigin.x, y: localOrigin.y, z: localOrigin.z }, + direction: { x: localDir.x, y: localDir.y, z: localDir.z }, + }, + nodeSpheres, + 4000 + ) + } + + setAimHit(hit) + + // World-space aim point for rendering (hit point if present; otherwise plane intersection near graph center) + let aimPointWorld: THREE.Vector3 | null = null + if (group && hit) { + aimPointWorld = new THREE.Vector3(hit.point.x, hit.point.y, hit.point.z).applyMatrix4(group.matrixWorld) + } else if (group) { + const center = group.getWorldPosition(new THREE.Vector3()) + const normal = camera.getWorldDirection(new THREE.Vector3()).normalize() + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, center) + const ray = new THREE.Ray(origin, direction) + aimPointWorld = ray.intersectPlane(plane, new THREE.Vector3()) || null + } + if (!aimPointWorld) { + aimPointWorld = origin.clone().add(direction.clone().multiplyScalar(250)) + } + setAimWorldPoint({ x: aimPointWorld.x, y: aimPointWorld.y, z: aimPointWorld.z }) + + // Hover highlight + const hoverNode = hit ? (nodeById.get(hit.nodeId) ?? null) : null + onNodeHover(hoverNode as GraphNode | null) + + // Pinch click (only when aiming). Trigger on release to allow cancel. + const pinchActive = m.pinch > 0.75 + if (pinchActive && !pinchDownRef.current) { + pinchDownRef.current = true + pressedNodeRef.current = hit?.nodeId ?? null + return + } + + if (!pinchActive && pinchDownRef.current) { + pinchDownRef.current = false + const pressed = pressedNodeRef.current + pressedNodeRef.current = null + if (pressed && hit?.nodeId === pressed) { + selectNodeById(pressed, hit.point) + } + } + }, [ + gestureControlEnabled, + camera, + nodeSpheres, + nodeById, + onNodeHover, + selectNodeById, + gestureState.leftHand, + gestureState.rightHand, + handLock.mode, + (handLock as any).hand, + (handLock as any).grabbed, + (handLock as any).metrics?.point, + (handLock as any).metrics?.pinch, + ]) + // Filter nodes based on search const searchLower = searchTerm.toLowerCase() const matchingIds = useMemo(() => { @@ -391,27 +547,49 @@ function Scene({ )} - {/* Laser pointers - rendered in world space for accurate targeting */} - {gestureControlEnabled && interactionState.leftRay && ( - = (interactionState.rightRay?.confidence ?? 0) - ? interactionState.hoveredNode - : null} - color="#4ecdc4" - showArmModel={false} - /> - )} - {gestureControlEnabled && interactionState.rightRay && ( - (interactionState.leftRay?.confidence ?? 0) - ? interactionState.hoveredNode - : null} - color="#f72585" - showArmModel={false} - /> - )} + {/* Aim cursor + laser (Meta-style: index aims, pinch clicks). Laser only appears while pinching. */} + {gestureControlEnabled && + handLock.mode === 'locked' && + !handLock.grabbed && + handLock.metrics.point > 0.55 && + aimWorldPoint && ( + <> + {/* Cursor at aim point (hit point when hovering a node; otherwise a plane in front of the cloud) */} + + 0.75 ? (aimHit ? 0.9 : 0.7) : (aimHit ? 0.55 : 0.4), 16, 16]} /> + 0.75 ? '#ffffff' : aimHit ? '#fbbf24' : '#94a3b8'} + transparent + opacity={0.9} + /> + + + {/* Laser beam only while pinching */} + {handLock.metrics.pinch > 0.75 && aimRayRef.current && ( + + + + + + + )} + + )} {/* Post-processing effects - conditional based on performance mode */} {!performanceMode && ( diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 0502a44..5ba7b20 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -13,6 +13,7 @@ import { useState, useEffect, useRef } from 'react' import type { GestureState, PinchRay } from '../hooks/useHandGestures' import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' +import type { HandLockState } from '../hooks/useHandLockAndGrab' // Fingertip indices const FINGERTIPS = [4, 8, 12, 16, 20] @@ -46,6 +47,8 @@ interface Hand2DOverlayProps { rightStableRay?: StableRay | null /** Current node hit (if any) */ hoveredNode?: NodeHit | null + /** Optional lock/grab state for nicer visuals */ + handLock?: HandLockState } export function Hand2DOverlay({ @@ -55,6 +58,7 @@ export function Hand2DOverlay({ leftStableRay, rightStableRay, hoveredNode, + handLock, }: Hand2DOverlayProps) { // Track smoothed hand positions with ghost effect const [leftSmoothed, setLeftSmoothed] = useState(null) @@ -152,6 +156,19 @@ export function Hand2DOverlay({ if (!enabled || !gestureState.isTracking) return null + // Visual state: ghosty until locked; solid when actively grabbing/pinching + const lockMode = handLock?.mode + const isLocked = lockMode === 'locked' + const isActive = + isLocked && (((handLock as any).grabbed as boolean) || (((handLock as any).metrics?.pinch as number) ?? 0) > 0.75) + const opacityMultiplier = !handLock + ? 1 + : lockMode === 'candidate' + ? 0.55 + : isLocked + ? (isActive ? 1.1 : 0.8) + : 0.6 + // Check if both hands are gripping (for two-hand manipulation) const leftGripping = gestureState.leftPinchRay?.isValid const rightGripping = gestureState.rightPinchRay?.isValid @@ -207,6 +224,7 @@ export function Hand2DOverlay({ color="#4ecdc4" gradientId="hand-gradient-cyan" isGhost={leftSmoothed.isGhost} + opacityMultiplier={opacityMultiplier} /> )} @@ -219,6 +237,7 @@ export function Hand2DOverlay({ color="#f72585" gradientId="hand-gradient-magenta" isGhost={rightSmoothed.isGhost} + opacityMultiplier={opacityMultiplier} /> )} @@ -237,8 +256,8 @@ export function Hand2DOverlay({ /> )} - {/* Left laser - use stable ray if available, otherwise fall back to basic ray */} - {showLaser && gestureState.leftPinchRay && gestureState.leftPinchRay.strength > 0.3 && ( + {/* Left laser - require stable ray to avoid misleading "center-biased" fallback */} + {showLaser && leftStableRay?.screenHit && gestureState.leftPinchRay && gestureState.leftPinchRay.strength > 0.3 && ( 0.3 && ( + {showLaser && rightStableRay?.screenHit && gestureState.rightPinchRay && gestureState.rightPinchRay.strength > 0.3 && ( 0.5 + const clamp01 = (v: number) => Math.max(0, Math.min(1, v)) + const t = isMeters ? clamp01((wristZ - 0.25) / 1.75) : clamp01((wristZ + 0.2) / 0.4) + const clampedScale = 0.6 + t * 1.1 // 0.6 .. 1.7 // Un-mirror the X coordinate const toSvg = (lm: { x: number; y: number }) => ({ @@ -297,7 +327,7 @@ function GhostHand({ landmarks, color: _color, gradientId: _gradientId, isGhost }) const points = landmarks.map(toSvg) - const gloveOpacity = isGhost ? 0.5 : 0.85 + const gloveOpacity = (isGhost ? 0.5 : 0.85) * opacityMultiplier // Finger width based on scale - fatter fingers for Master Hand look const fingerWidth = 1.8 * clampedScale diff --git a/packages/graph-viewer/src/components/HandControlOverlay.tsx b/packages/graph-viewer/src/components/HandControlOverlay.tsx index 474078b..7736421 100644 --- a/packages/graph-viewer/src/components/HandControlOverlay.tsx +++ b/packages/graph-viewer/src/components/HandControlOverlay.tsx @@ -43,6 +43,14 @@ export function HandControlOverlay({ enabled, lock, source }: HandControlOverlay palm {m.palmFacing.toFixed(2)}
+
+ point + {m.point.toFixed(2)} +
+
+ pinch + {m.pinch.toFixed(2)} +
grab {m.grab.toFixed(2)} @@ -59,7 +67,10 @@ export function HandControlOverlay({ enabled, lock, source }: HandControlOverlay Acquire: raise open palm + spread fingers
- Manipulate: make fist; pull/push to zoom; move to rotate + Navigate: make fist; pull/push to zoom; move to rotate +
+
+ Select: point (index out) + pinch thumb/index to click
diff --git a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts index 72517b9..b064ef8 100644 --- a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts +++ b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts @@ -22,6 +22,10 @@ export interface HandLockMetrics { spread: number /** -1..1: palm facing camera confidence-ish (1 = facing camera) */ palmFacing: number + /** 0..1: pointing pose score (index extended, others curled) */ + point: number + /** 0..1: pinch strength (thumb-index) */ + pinch: number /** 0..1: fist/grab strength (1 = closed fist) */ grab: number /** depth signal (meters for iPhone LiDAR when available, otherwise MediaPipe-relative) */ @@ -86,6 +90,17 @@ function safeDiv(a: number, b: number, fallback = 0) { return b !== 0 ? a / b : fallback } +function fingerExtensionScore( + wrist: { x: number; y: number }, + mcp: { x: number; y: number }, + tip: { x: number; y: number } +) { + // Extension proxy: tip should be noticeably farther from wrist than MCP when finger is extended. + const dTip = length2(tip.x - wrist.x, tip.y - wrist.y) + const dMcp = length2(mcp.x - wrist.x, mcp.y - wrist.y) + return clamp(safeDiv(dTip - dMcp - 0.02, 0.10), 0, 1) +} + /** * Compute simple metrics from landmarks (MediaPipe-style normalized 0..1) * Works for both sources because iPhone data is mapped into GestureState landmarks. @@ -99,8 +114,11 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | const wrist = lm[0] const indexMcp = lm[5] const middleMcp = lm[9] + const ringMcp = lm[13] + const pinkyMcp = lm[17] // Fingertips + const thumbTip = lm[4] const indexTip = lm[8] const middleTip = lm[12] const ringTip = lm[16] @@ -122,6 +140,20 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | // (This is crude but works for the acquisition gesture.) const palmFacing = clamp(safeDiv((wrist.y - (indexMcp.y + middleMcp.y) / 2) - 0.02, 0.12), 0, 1) * 2 - 1 + // Pinch (thumb-index) + const pinchDist = length2(thumbTip.x - indexTip.x, thumbTip.y - indexTip.y) + const pinch = clamp(1 - safeDiv(pinchDist - 0.02, 0.13), 0, 1) + + // Pointing pose score: + // index extended while the other 3 fingers are relatively curled. + const idxExt = fingerExtensionScore(wrist, indexMcp, indexTip) + const midExt = fingerExtensionScore(wrist, middleMcp, middleTip) + const ringExt = fingerExtensionScore(wrist, ringMcp, ringTip) + const pinkyExt = fingerExtensionScore(wrist, pinkyMcp, pinkyTip) + const others = clamp((midExt + ringExt + pinkyExt) / 3, 0, 1) + const palmForward = clamp((palmFacing + 1) / 2, 0, 1) + const point = clamp(idxExt * (1 - others) * palmForward, 0, 1) + // Grab: use state.grabStrength only if it's meaningful (computed by source); // otherwise approximate from fingertip distances to wrist (closed fist => smaller) const hasGrabStrength = @@ -147,7 +179,7 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | const vis = (wrist as any).visibility const confidence = typeof vis === 'number' ? clamp(vis, 0, 1) : DEFAULT_CONFIDENCE - return { spread, palmFacing, grab, depth, confidence } + return { spread, palmFacing, point, pinch, grab, depth, confidence } } function isAcquirePose(m: HandLockMetrics) { @@ -265,6 +297,7 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { } // Depth -> zoom (exponential) + // User mental model: pull fist toward body (farther from camera) zooms IN; push toward screen/camera zooms OUT. const dz = metrics.depth - anchor.depth if (Math.abs(dz) > DEPTH_DEADZONE) { deltas.zoom = expResponse(dz, DEPTH_GAIN) @@ -293,6 +326,8 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { // metrics is a new object each render; depend on its fields instead metrics?.spread, metrics?.palmFacing, + metrics?.point, + metrics?.pinch, metrics?.grab, metrics?.depth, metrics?.confidence, diff --git a/packages/graph-viewer/vite.config.ts b/packages/graph-viewer/vite.config.ts index c28fd54..0029998 100644 --- a/packages/graph-viewer/vite.config.ts +++ b/packages/graph-viewer/vite.config.ts @@ -14,21 +14,26 @@ export default defineConfig({ server: { port: 5173, proxy: { + // Proxy to Railway backend (or override via VITE_API_TARGET env var) '/graph': { - target: 'http://localhost:8001', + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', changeOrigin: true, + secure: true, }, '/recall': { - target: 'http://localhost:8001', + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', changeOrigin: true, + secure: true, }, '/memory': { - target: 'http://localhost:8001', + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', changeOrigin: true, + secure: true, }, '/health': { - target: 'http://localhost:8001', + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', changeOrigin: true, + secure: true, }, }, }, From 20958698191d7d0d62a6edc3f97b22f06fa3fd64 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 18 Dec 2025 09:39:08 +0100 Subject: [PATCH 24/47] feat(graph-viewer): improve hand tracking bridge status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dev:all launcher for starting the iPhone bridge + Vite together, and upgrades the UI/bridge status reporting (browser↔bridge, phone↔bridge, LiDAR availability, ports/IPs) for easier debugging. --- packages/graph-viewer/package.json | 1 + packages/graph-viewer/scripts/dev-all.mjs | 76 +++++++++++++++++++ .../scripts/hand-tracking-server.js | 67 +++++++++++++--- packages/graph-viewer/src/App.tsx | 35 ++++++++- .../src/components/GraphCanvas.tsx | 31 +++++++- .../src/components/HandControlOverlay.tsx | 58 +++++++++++++- .../src/hooks/useIPhoneHandTracking.ts | 16 ++++ 7 files changed, 265 insertions(+), 19 deletions(-) create mode 100644 packages/graph-viewer/scripts/dev-all.mjs diff --git a/packages/graph-viewer/package.json b/packages/graph-viewer/package.json index 7640e70..28a7b71 100644 --- a/packages/graph-viewer/package.json +++ b/packages/graph-viewer/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:all": "node ./scripts/dev-all.mjs", "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint ." diff --git a/packages/graph-viewer/scripts/dev-all.mjs b/packages/graph-viewer/scripts/dev-all.mjs new file mode 100644 index 0000000..4b01b59 --- /dev/null +++ b/packages/graph-viewer/scripts/dev-all.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Dev launcher: start BOTH + * - iPhone hand-tracking bridge (ws phone + ws/http web) + * - Graph viewer Vite dev server + * + * Goal: remove the “forgot to start the bridge” failure mode. + */ + +import { execSync, spawn } from 'node:child_process' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const viewerRoot = join(__dirname, '..') + +// Match defaults in hand-tracking-server.js +const PHONE_PORT = Number(process.env.HAND_TRACKING_PHONE_PORT || 8768) +const WEB_PORT = Number(process.env.HAND_TRACKING_WEB_PORT || 8766) + +function safeKillPorts() { + if (process.platform !== 'darwin' && process.platform !== 'linux') return + try { + execSync( + [ + `lsof -ti:${PHONE_PORT} | xargs kill -9 2>/dev/null || true`, + `lsof -ti:${WEB_PORT} | xargs kill -9 2>/dev/null || true`, + ].join('; '), + { stdio: 'ignore', shell: '/bin/bash' } + ) + } catch { + // ignore + } +} + +function run(cmd, args, opts) { + const child = spawn(cmd, args, { + stdio: 'inherit', + ...opts, + }) + child.on('exit', (code) => { + if (code && code !== 0) { + process.exitCode = code + } + }) + return child +} + +console.log('\n🧠 graph-viewer dev:all\n') +console.log(`Using ports: phone=${PHONE_PORT} web=${WEB_PORT}`) +console.log('Clearing any old listeners on those ports...') +safeKillPorts() + +console.log('\nStarting iPhone hand-tracking bridge...') +const bridge = run(process.execPath, ['hand-tracking-server.js'], { + cwd: __dirname, + env: { + ...process.env, + HAND_TRACKING_PHONE_PORT: String(PHONE_PORT), + HAND_TRACKING_WEB_PORT: String(WEB_PORT), + }, +}) + +console.log('\nStarting Vite dev server...') +const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm' +const vite = run(npmCmd, ['run', 'dev'], { cwd: viewerRoot, env: process.env }) + +const shutdown = (signal) => { + try { bridge.kill(signal) } catch {} + try { vite.kill(signal) } catch {} + process.exit(0) +} + +process.on('SIGINT', () => shutdown('SIGINT')) +process.on('SIGTERM', () => shutdown('SIGTERM')) diff --git a/packages/graph-viewer/scripts/hand-tracking-server.js b/packages/graph-viewer/scripts/hand-tracking-server.js index 4999fd3..cf1cd30 100644 --- a/packages/graph-viewer/scripts/hand-tracking-server.js +++ b/packages/graph-viewer/scripts/hand-tracking-server.js @@ -17,12 +17,47 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); -const PHONE_PORT = 8765; // iPhone connects here -const WEB_PORT = 8766; // Web visualization +// NOTE: 8765/8767 are often used by other local tooling. Default to 8768 to avoid collisions. +// Override via env: +// HAND_TRACKING_PHONE_PORT=8765 HAND_TRACKING_WEB_PORT=8766 node hand-tracking-server.js +const PHONE_PORT = Number(process.env.HAND_TRACKING_PHONE_PORT || 8768); // iPhone connects here +const WEB_PORT = Number(process.env.HAND_TRACKING_WEB_PORT || 8766); // Web visualization // Store latest hand data let latestHandData = null; let webClients = new Set(); +let phoneClients = new Set(); + +function getLocalIps() { + // Get local IP for convenience + const { networkInterfaces } = require('os'); + const nets = networkInterfaces(); + const ips = []; + for (const name of Object.keys(nets)) { + for (const net of nets[name]) { + if (net.family === 'IPv4' && !net.internal) { + ips.push(net.address); + } + } + } + return ips; +} + +function broadcastStatus() { + const msg = JSON.stringify({ + type: 'bridge_status', + phonePort: PHONE_PORT, + webPort: WEB_PORT, + phoneConnected: phoneClients.size > 0, + ips: getLocalIps(), + lastHandFrameAt: latestHandData?.frameTimestamp || null, + }); + for (const client of webClients) { + if (client.readyState === WebSocket.OPEN) { + client.send(msg); + } + } +} // ============ iPhone WebSocket Server ============ @@ -33,6 +68,8 @@ console.log(` Connect your iPhone app to: ws://:${PHONE_PORT}`); phoneServer.on('connection', (ws, req) => { console.log(`\n✅ iPhone connected from ${req.socket.remoteAddress}`); + phoneClients.add(ws); + broadcastStatus(); ws.on('message', (data) => { try { @@ -82,6 +119,8 @@ phoneServer.on('connection', (ws, req) => { ws.on('close', () => { console.log('\n📱 iPhone disconnected'); + phoneClients.delete(ws); + broadcastStatus(); }); ws.on('error', (err) => { @@ -188,7 +227,7 @@ const webHtml = ` ]; function connect() { - const ws = new WebSocket('ws://localhost:8766/ws'); + const ws = new WebSocket('ws://localhost:${WEB_PORT}/ws'); ws.onopen = () => { statusEl.textContent = 'Connected'; @@ -320,6 +359,17 @@ const webWss = new WebSocketServer({ server: httpServer, path: '/ws' }); webWss.on('connection', (ws) => { console.log('🌐 Web client connected'); webClients.add(ws); + // Send current status immediately + try { + ws.send(JSON.stringify({ + type: 'bridge_status', + phonePort: PHONE_PORT, + webPort: WEB_PORT, + phoneConnected: phoneClients.size > 0, + ips: getLocalIps(), + lastHandFrameAt: latestHandData?.frameTimestamp || null, + })); + } catch {} ws.on('close', () => { console.log('🌐 Web client disconnected'); @@ -331,15 +381,8 @@ httpServer.listen(WEB_PORT, () => { console.log(`\n🌐 Web visualization at http://localhost:${WEB_PORT}`); }); -// Get local IP for convenience -const { networkInterfaces } = require('os'); -const nets = networkInterfaces(); console.log('\n📡 Your Mac IP addresses:'); -for (const name of Object.keys(nets)) { - for (const net of nets[name]) { - if (net.family === 'IPv4' && !net.internal) { - console.log(` ${name}: ${net.address}`); - } - } +for (const ip of getLocalIps()) { + console.log(` ${ip}`); } console.log('\n'); diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index aa62947..1ec052a 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -88,6 +88,23 @@ export default function App() { const [debugOverlayVisible, setDebugOverlayVisible] = useState(false) const [performanceMode, setPerformanceMode] = useState(false) const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) + const [trackingInfo, setTrackingInfo] = useState<{ + source: 'mediapipe' | 'iphone' + iphoneUrl: string + iphoneConnected: boolean + hasLiDAR: boolean + phoneConnected: boolean + bridgeIps: string[] + phonePort: number | null + }>({ + source: 'mediapipe', + iphoneUrl: 'ws://localhost:8766/ws', + iphoneConnected: false, + hasLiDAR: false, + phoneConnected: false, + bridgeIps: [], + phonePort: null, + }) const [filters, setFilters] = useState({ types: [], minImportance: 0, @@ -99,9 +116,8 @@ export default function App() { }, []) const { lock: handLock } = useHandLockAndGrab(gestureState, gestureControlEnabled) - const trackingSource = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('iphone') === 'true' - ? 'iphone' - : 'mediapipe' + // Note: GraphCanvas owns the actual tracking source selection via URL params. + // We mirror it here via onTrackingInfoChange so overlays can show accurate status. const { data, isLoading, error, refetch } = useGraphSnapshot({ limit: filters.maxNodes, @@ -258,6 +274,7 @@ export default function App() { onNodeHover={handleNodeHover} gestureControlEnabled={gestureControlEnabled} onGestureStateChange={handleGestureStateChange} + onTrackingInfoChange={setTrackingInfo} performanceMode={performanceMode} /> @@ -276,7 +293,17 @@ export default function App() { /> {/* Hand Control Overlay (lock/grab metrics) */} - +
diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 2db56c9..a2cb8df 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -72,6 +72,15 @@ interface GraphCanvasProps { onNodeHover: (node: GraphNode | null) => void gestureControlEnabled?: boolean onGestureStateChange?: (state: GestureState) => void + onTrackingInfoChange?: (info: { + source: 'mediapipe' | 'iphone' + iphoneUrl: string + iphoneConnected: boolean + hasLiDAR: boolean + phoneConnected: boolean + bridgeIps: string[] + phonePort: number | null + }) => void performanceMode?: boolean // New prop for disabling post-processing } @@ -85,6 +94,7 @@ export function GraphCanvas({ onNodeHover, gestureControlEnabled = false, onGestureStateChange, + onTrackingInfoChange, performanceMode = false, }: GraphCanvasProps) { // Determine tracking source @@ -97,7 +107,14 @@ export function GraphCanvas({ }) // iPhone hand tracking (WebSocket) - const { gestureState: iphoneState, isConnected: iphoneConnected } = useIPhoneHandTracking({ + const { + gestureState: iphoneState, + isConnected: iphoneConnected, + hasLiDAR, + phoneConnected, + bridgeIps, + phonePort, + } = useIPhoneHandTracking({ enabled: gestureControlEnabled && source === 'iphone', serverUrl: iphoneUrl, onGestureChange: source === 'iphone' ? onGestureStateChange : undefined, @@ -107,6 +124,18 @@ export function GraphCanvas({ const gestureState = source === 'iphone' ? iphoneState : mediapipeState const gesturesActive = source === 'iphone' ? iphoneConnected : mediapipeActive + useEffect(() => { + onTrackingInfoChange?.({ + source, + iphoneUrl, + iphoneConnected, + hasLiDAR, + phoneConnected, + bridgeIps, + phonePort, + }) + }, [onTrackingInfoChange, source, iphoneUrl, iphoneConnected, hasLiDAR, phoneConnected, bridgeIps, phonePort]) + return ( Source - {source === 'iphone' ? 'iPhone (LiDAR)' : 'Webcam (MediaPipe)'} + + {source === 'iphone' ? 'iPhone Stream' : 'Webcam (MediaPipe)'} +
+ {source === 'iphone' && ( + <> +
+ Browser → Bridge + + {iphoneConnected ? 'Connected' : 'Disconnected'} + +
+
+ Phone → Bridge + + {phoneConnected ? 'Connected' : 'Disconnected'} + +
+
+ LiDAR + + {hasLiDAR ? '✓ depth frames' : '✗ no depth'} + +
+ {iphoneUrl && ( +
+ ws: {iphoneUrl} +
+ )} + {!phoneConnected && bridgeIps.length > 0 && phonePort && ( +
+ iPhone app URL:{' '} + + ws://{bridgeIps[0]}:{phonePort} + +
+ )} + + )} + {m && (
diff --git a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts index 9012c0c..e8953bc 100644 --- a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts +++ b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts @@ -54,6 +54,11 @@ interface IPhoneMessage { type: string hands: IPhoneHandPose[] frameTimestamp: number + phonePort?: number + webPort?: number + phoneConnected?: boolean + ips?: string[] + lastHandFrameAt?: number | null } interface UseIPhoneHandTrackingOptions { @@ -199,6 +204,9 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} const [isConnected, setIsConnected] = useState(false) const [fps, setFps] = useState(0) const [hasLiDAR, setHasLiDAR] = useState(false) + const [phoneConnected, setPhoneConnected] = useState(false) + const [bridgeIps, setBridgeIps] = useState([]) + const [phonePort, setPhonePort] = useState(null) const wsRef = useRef(null) const prevStateRef = useRef(DEFAULT_STATE) @@ -323,6 +331,10 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} const data = JSON.parse(event.data) as IPhoneMessage if (data.type === 'hand_tracking') { processMessage(data) + } else if (data.type === 'bridge_status') { + if (typeof data.phoneConnected === 'boolean') setPhoneConnected(data.phoneConnected) + if (Array.isArray(data.ips)) setBridgeIps(data.ips) + if (typeof data.phonePort === 'number') setPhonePort(data.phonePort) } } catch (e) { console.error('Parse error:', e) @@ -333,6 +345,7 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} console.log('📱 Disconnected from iPhone') setIsConnected(false) setGestureState(DEFAULT_STATE) + setPhoneConnected(false) // Reconnect after delay if (enabled) { @@ -364,6 +377,9 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} isConnected, fps, hasLiDAR, + phoneConnected, + bridgeIps, + phonePort, isEnabled: enabled && isConnected, } } From 366005fb0d71e4cbc19a006ba0bb84d47ebe3320 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 20:48:06 +0000 Subject: [PATCH 25/47] docs: add visualizer enhancement plan for Obsidian-style UI Plan to improve memory display and organization while keeping hand gesture features. Key additions: - Obsidian-style settings panel (filters, display, forces, clustering) - Multi-layer relationship visualization with styled edges - Smart clustering (by type, tags, or semantic similarity) - Enhanced selection focus mode with context highlighting - Keyboard shortcuts supplementing gesture navigation Research sources included for graph visualization best practices. --- VISUALIZER_SIMPLIFICATION_PLAN.md | 438 ++++++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 VISUALIZER_SIMPLIFICATION_PLAN.md diff --git a/VISUALIZER_SIMPLIFICATION_PLAN.md b/VISUALIZER_SIMPLIFICATION_PLAN.md new file mode 100644 index 0000000..3593f9e --- /dev/null +++ b/VISUALIZER_SIMPLIFICATION_PLAN.md @@ -0,0 +1,438 @@ +# AutoMem Visualizer Enhancement Plan + +## Overview + +This plan focuses on improving memory display and organization in the graph visualizer, inspired by Obsidian's graph view UX. **Hand gesture features are retained** as a differentiating capability. The goal is better clustering, navigation, and context-aware selection UI. + +## Current State Analysis + +### Existing Capabilities (Keep All) +- **3D Force Layout** - d3-force-3d with Fibonacci sphere initialization +- **Hand Gestures** - MediaPipe webcam + iPhone hand tracking integration +- **Post-processing** - Bloom, vignette (performance mode toggle) +- **Inspector Panel** - Node details with relationships +- **API Integration** - React Query for data fetching + +### Data Available from AutoMem API + +**Memory Nodes:** +- `id`, `content`, `type` (8 types), `importance`, `confidence` +- `tags` (categorical grouping) +- `timestamp`, `metadata` +- Computed: `color`, `radius`, `opacity` + +**11 Relationship Types:** +| Type | Category | Use for Clustering | +|------|----------|-------------------| +| RELATES_TO | General | Medium weight | +| LEADS_TO | Causal | High weight | +| OCCURRED_BEFORE | Temporal | Sequence flow | +| PREFERS_OVER | Preference | User patterns | +| EXEMPLIFIES | Pattern | Strong cluster signal | +| CONTRADICTS | Conflict | Separate clusters | +| REINFORCES | Support | Strong cluster signal | +| INVALIDATED_BY | Superseded | Temporal layering | +| EVOLVED_INTO | Evolution | Flow direction | +| DERIVED_FROM | Source | Hierarchy | +| PART_OF | Hierarchical | Sub-clustering | + +**Semantic Similarity:** +- Vector embeddings (3072-dim) from Qdrant +- Cosine similarity scores for any pair +- Accessible via `/graph/neighbors` endpoint + +### What Needs Improvement +1. **Node clustering** - Currently random placement; no visual grouping +2. **Relationship visualization** - All edges look the same +3. **Selection context** - Inspector exists but not like Obsidian's focus mode +4. **Settings/controls** - Limited to filter dropdown; no force controls +5. **Navigation** - Relies heavily on gestures; needs mouse/keyboard fallbacks + +--- + +## Proposed Enhancements + +### Phase 1: Obsidian-Style Settings Panel (Right-Docked) + +Create `SettingsPanel.tsx` with collapsible sections: + +``` +┌─────────────────────────────────┐ +│ ⚙ Graph Settings ✕ │ +├─────────────────────────────────┤ +│ ▼ Filters │ +│ Memory Types [Decision ▼] │ +│ Tags [Select... ▼] │ +│ Min Importance ━━━━●━━━ 0.3 │ +│ Show Orphans [✓] │ +│ Show Only Connected [ ] │ +├─────────────────────────────────┤ +│ ▼ Relationships │ +│ [✓] RELATES_TO ────── │ +│ [✓] LEADS_TO ━━━━━━ │ +│ [✓] EXEMPLIFIES ·········· │ +│ [✓] CONTRADICTS ─ ─ ─ ─ │ +│ [ ] SIMILAR_TO ∿∿∿∿∿∿ │ +│ ... (toggles for all 11+) │ +├─────────────────────────────────┤ +│ ▼ Display │ +│ Show Labels [✓] │ +│ Label Fade Distance ━━●━ 80 │ +│ Show Arrows [ ] │ +│ Node Size Scale ━●━━ 1.0 │ +│ Link Thickness ━●━━ 1.0 │ +│ Link Opacity ━━●━ 0.6 │ +├─────────────────────────────────┤ +│ ▼ Clustering │ +│ Mode: ○ Type ● Tags ○ Semantic│ +│ Show Boundaries [✓] │ +│ Cluster Strength ━━●━ 0.5 │ +│ [Detect Clusters] │ +├─────────────────────────────────┤ +│ ▼ Forces │ +│ Center Force ━●━━ 0.05 │ +│ Repel Force ━━●━ -100 │ +│ Link Force ━●━━ 0.5 │ +│ Link Distance ━━●━ 50 │ +│ Collision Radius ━●━━ 2.0 │ +│ │ +│ [Reheat] [Reset to Defaults] │ +└─────────────────────────────────┘ +``` + +### Phase 2: Multi-Layer Relationship Visualization + +**Edge Styling by Type:** + +```typescript +const EDGE_STYLES: Record = { + // Causal/Flow (solid, directional) + LEADS_TO: { stroke: '#3B82F6', dash: null, width: 2, arrow: true }, + EVOLVED_INTO: { stroke: '#06B6D4', dash: null, width: 1.5, arrow: true }, + DERIVED_FROM: { stroke: '#A855F7', dash: null, width: 1.5, arrow: true }, + + // Temporal (dashed) + OCCURRED_BEFORE: { stroke: '#6B7280', dash: [4, 2], width: 1, arrow: true }, + INVALIDATED_BY: { stroke: '#F97316', dash: [4, 2], width: 1, arrow: true }, + + // Semantic/Association (dotted) + RELATES_TO: { stroke: '#94A3B8', dash: [2, 2], width: 1, arrow: false }, + EXEMPLIFIES: { stroke: '#10B981', dash: [2, 2], width: 1.5, arrow: false }, + REINFORCES: { stroke: '#22C55E', dash: [2, 2], width: 1.5, arrow: false }, + + // Conflict (red, special) + CONTRADICTS: { stroke: '#EF4444', dash: [6, 3], width: 2, arrow: false }, + + // Preference/Hierarchy + PREFERS_OVER: { stroke: '#8B5CF6', dash: null, width: 1, arrow: true }, + PART_OF: { stroke: '#64748B', dash: null, width: 1, arrow: true }, + + // Semantic similarity (virtual edges from Qdrant) + SIMILAR_TO: { stroke: '#94A3B8', dash: [1, 3], width: 0.5, arrow: false, opacity: 0.3 }, +} +``` + +**Relationship Layers:** +- Toggle visibility per relationship type +- Option to show semantic similarity edges (from Qdrant vectors) +- Edge labels on hover showing type and strength + +### Phase 3: Smart Clustering + +**Three Clustering Modes:** + +1. **By Memory Type** (default) + - Group nodes by type (Decision, Pattern, Preference, etc.) + - Use type colors + - Add subtle force to pull same-type nodes together + +2. **By Tags** + - Analyze shared tags across memories + - Create cluster groups for common tag combinations + - Color nodes by dominant tag cluster + - Show tag labels on cluster boundaries + +3. **By Semantic Similarity** + - Use vector embeddings to compute pairwise distances + - Apply community detection (Louvain or similar) + - Color by detected cluster + - Label clusters by common themes/keywords + +**Visual Cluster Boundaries:** +- Subtle dotted circles around cluster groups (like Obsidian screenshot) +- Fade in/out based on zoom level +- Click boundary to select all nodes in cluster +- Hover to see cluster statistics + +**Implementation:** + +```typescript +// hooks/useClusterDetection.ts +interface ClusterConfig { + mode: 'type' | 'tags' | 'semantic'; + minClusterSize: number; + showBoundaries: boolean; + clusterStrength: number; // Additional force pulling cluster members together +} + +interface Cluster { + id: string; + label: string; + nodeIds: Set; + center: { x: number; y: number; z: number }; + radius: number; + color: string; +} +``` + +### Phase 4: Enhanced Selection & Focus Mode (Obsidian-Style) + +When a node is selected: + +**Visual Changes:** +1. Selected node: Bright highlight, larger size, pulsing glow +2. Connected nodes (1-hop): Highlighted at 80% intensity +3. Connected nodes (2-hop): Highlighted at 50% intensity (optional) +4. Unconnected nodes: Fade to 20% opacity +5. Relevant edges: Highlighted, thicker +6. Irrelevant edges: Fade to 10% opacity + +**Inspector Panel Enhancement:** + +``` +┌─────────────────────────────────────┐ +│ 🔵 Decision ✕ │ +│ UUID: abc-123... │ +├─────────────────────────────────────┤ +│ Content │ +│ ┌─────────────────────────────────┐ │ +│ │ User prefers TypeScript over │ │ +│ │ JavaScript for new projects... │ │ +│ └─────────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ 📊 Importance ━━━━━━━●━━━ 0.85 │ +│ 🎯 Confidence ━━━━━━━━●━ 0.92 │ +├─────────────────────────────────────┤ +│ 🏷️ Tags │ +│ [typescript] [preferences] [coding] │ +├─────────────────────────────────────┤ +│ ▼ Graph Relationships (5) │ +│ ┌─────────────────────────────────┐ │ +│ │ → LEADS_TO │ │ +│ │ "Adopted ESLint strict..." │ │ +│ │ Strength: 0.8 [Navigate →] │ │ +│ ├─────────────────────────────────┤ │ +│ │ ← REINFORCES │ │ +│ │ "Team agreed on TS for..." │ │ +│ │ Strength: 0.9 [Navigate →] │ │ +│ └─────────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ ▼ Similar Memories (3) │ +│ ┌─────────────────────────────────┐ │ +│ │ 🟣 Preference 92% similar │ │ +│ │ "Prefers React over Vue..." │ │ +│ │ [Navigate →] [Show Edge] │ │ +│ └─────────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ [Edit Importance] [Delete Memory] │ +└─────────────────────────────────────┘ +``` + +**Focus Mode Features:** +- "Show Edge" button to temporarily display semantic similarity edge +- Clicking related memory navigates + updates selection +- Breadcrumb trail of navigation history +- "Back" button to return to previous selection + +### Phase 5: Improved Force Layout Configuration + +**Enhanced `useForceLayout.ts`:** + +```typescript +interface ForceConfig { + // Core forces + centerStrength: number; // 0.01 - 0.2, default 0.05 + chargeStrength: number; // -200 to -50, default -100 + linkStrength: number; // 0.1 - 1.0, default 0.5 + linkDistance: number; // 20 - 100, default 50 + collisionRadius: number; // 1.0 - 4.0, default 2.0 + + // Clustering forces (new) + clusterStrength: number; // 0 - 1.0, additional pull toward cluster center + + // Relationship-weighted links (new) + relationshipWeights: Record; + // e.g., LEADS_TO: 1.0, RELATES_TO: 0.3, CONTRADICTS: -0.2 + + // Animation + alphaDecay: number; // 0.01 - 0.05 + velocityDecay: number; // 0.2 - 0.5 +} +``` + +**Relationship-Weighted Forces:** +- Stronger relationships (LEADS_TO, REINFORCES) pull nodes closer +- Weaker relationships (RELATES_TO) have looser coupling +- CONTRADICTS can have slight repulsion within cluster + +### Phase 6: Keyboard & Mouse Navigation (Supplement to Gestures) + +**Keyboard Shortcuts:** +| Key | Action | +|-----|--------| +| `Escape` | Deselect current node | +| `R` | Reset view to initial position | +| `F` | Fit all nodes in view | +| `Space` | Reheat simulation | +| `Tab` | Cycle through connected nodes | +| `1-8` | Filter to memory type 1-8 | +| `Ctrl+F` | Focus search bar | +| `?` | Show keyboard shortcuts overlay | + +**Mouse Enhancements:** +- Double-click node: Focus + zoom to node +- Double-click empty: Reset view +- Right-click node: Context menu (navigate, edit, delete) +- Ctrl+click: Add to multi-selection +- Drag selection box: Select multiple nodes + +--- + +## Component Architecture + +``` +App.tsx +├── Header +│ ├── Logo + Title +│ ├── SearchBar (enhanced with suggestions) +│ └── StatsBar +│ +├── Main Content (PanelGroup - horizontal) +│ │ +│ ├── GraphCanvas +│ │ ├── Scene +│ │ │ ├── NodeInstances (instanced mesh) +│ │ │ ├── EdgeLines (batched, styled by type) +│ │ │ ├── SemanticEdges (optional similarity edges) +│ │ │ ├── Labels (billboard, LOD-based) +│ │ │ ├── ClusterBoundaries (dotted circles) +│ │ │ └── SelectionHighlight (focus mode overlay) +│ │ │ +│ │ ├── OrbitControls (always active as fallback) +│ │ │ +│ │ └── Hand Gesture Layer (existing - unchanged) +│ │ ├── Hand2DOverlay +│ │ ├── GestureDebugOverlay +│ │ └── HandControlOverlay +│ │ +│ ├── Inspector (enhanced - right panel) +│ │ ├── NodeDetails +│ │ ├── RelationshipList (grouped by type) +│ │ ├── SemanticNeighbors +│ │ └── ActionButtons +│ │ +│ └── SettingsPanel (new - right panel, collapsible) +│ ├── FiltersSection +│ ├── RelationshipsSection +│ ├── DisplaySection +│ ├── ClusteringSection +│ └── ForcesSection +│ +└── Overlays + ├── KeyboardShortcutsHelp + └── ClusterInfoTooltip +``` + +--- + +## New Files to Create + +| File | Purpose | +|------|---------| +| `components/SettingsPanel.tsx` | Main settings panel with all sections | +| `components/SettingsSection.tsx` | Collapsible section wrapper | +| `components/SliderControl.tsx` | Labeled slider with value display | +| `components/ToggleControl.tsx` | Labeled toggle switch | +| `components/RelationshipToggles.tsx` | Per-relationship visibility controls | +| `components/ClusterBoundaries.tsx` | 3D cluster boundary visualization | +| `components/SelectionHighlight.tsx` | Focus mode visual overlay | +| `components/EdgeRenderer.tsx` | Styled edges by relationship type | +| `components/SemanticEdges.tsx` | Optional similarity-based edges | +| `hooks/useClusterDetection.ts` | Cluster analysis logic | +| `hooks/useSelectionFocus.ts` | Focus mode state management | +| `hooks/useKeyboardShortcuts.ts` | Keyboard navigation | +| `lib/clusterUtils.ts` | Clustering algorithms (tag, semantic) | +| `lib/edgeStyles.ts` | Edge styling configuration | + +## Files to Modify + +| File | Changes | +|------|---------| +| `App.tsx` | Add SettingsPanel, keyboard handler | +| `GraphCanvas.tsx` | Add cluster boundaries, edge styling, focus mode | +| `useForceLayout.ts` | Add configurable forces, clustering force | +| `Inspector.tsx` | Enhanced relationship display, navigation | +| `FilterPanel.tsx` | Move content into SettingsPanel | +| `types.ts` | Add ClusterConfig, ForceConfig, EdgeStyle types | + +--- + +## Implementation Order + +### Sprint 1: Settings Panel Foundation +1. Create SettingsSection collapsible component +2. Create SliderControl and ToggleControl +3. Create SettingsPanel shell with sections +4. Wire up force configuration to useForceLayout +5. Add reheat button functionality + +### Sprint 2: Relationship Visualization +1. Define edge styles in edgeStyles.ts +2. Update EdgeLines rendering with styles +3. Add RelationshipToggles to settings +4. Implement edge visibility filtering +5. Add edge labels on hover + +### Sprint 3: Clustering +1. Implement useClusterDetection hook +2. Create ClusterBoundaries component +3. Add clustering mode selector to settings +4. Implement tag-based clustering +5. Implement semantic clustering (using Qdrant data) +6. Add cluster force to simulation + +### Sprint 4: Selection & Focus Mode +1. Implement useSelectionFocus hook +2. Add SelectionHighlight component +3. Enhance Inspector with navigation +4. Add focus mode opacity/highlight logic +5. Implement "Show Edge" for semantic neighbors + +### Sprint 5: Navigation Polish +1. Add useKeyboardShortcuts hook +2. Implement all keyboard shortcuts +3. Add double-click behaviors +4. Add multi-selection support +5. Add navigation breadcrumbs + +--- + +## Success Criteria + +1. **Better Clustering** - Related memories visually grouped with optional boundaries +2. **Relationship Clarity** - Edge types distinguishable by style/color +3. **Intuitive Selection** - Obsidian-style focus mode with context highlighting +4. **Configurable Forces** - Users can tune simulation to their preference +5. **Keyboard Navigation** - Full functionality without mouse/gestures +6. **Hand Gestures Preserved** - All existing gesture features work alongside new controls +7. **Performance** - Smooth 60fps with 500+ nodes + +--- + +## Research Sources + +- [yFiles Knowledge Graph Guide](https://www.yfiles.com/resources/how-to/guide-to-visualizing-knowledge-graphs) - Layout matching, visual encoding +- [Cambridge Intelligence - Graph Layouts](https://cambridge-intelligence.com/automatic-graph-layouts/) - Force-directed best practices +- [Datavid - Knowledge Graph Visualization](https://datavid.com/blog/knowledge-graph-visualization) - Multi-layer visualization +- [FalkorDB Visualization](https://www.falkordb.com/blog/knowledge-graph-visualization-insights/) - Real-time graph manipulation +- [2024 Graph Layout Evaluation](https://onlinelibrary.wiley.com/doi/10.1111/cgf.15073) - Evaluation methodologies From c9aa6b5f4947314b14f758a187cf2da2a71cd962 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 21:06:30 +0000 Subject: [PATCH 26/47] feat(graph-viewer): add Obsidian-style settings panel Sprint 1 implementation: - Add SettingsPanel with collapsible sections (Filters, Relationships, Display, Clustering, Forces) - Add SliderControl and ToggleControl UI components - Add ForceConfig, DisplayConfig, ClusterConfig types - Wire force configuration to useForceLayout hook - Add reheat button to restart force simulation - Settings panel docked to right side with toggle button This provides real-time control over graph visualization parameters including node size, link thickness, force strengths, and filtering. --- packages/graph-viewer/src/App.tsx | 269 ++++++++---- .../src/components/GraphCanvas.tsx | 40 +- .../src/components/settings/SettingsPanel.tsx | 405 ++++++++++++++++++ .../components/settings/SettingsSection.tsx | 33 ++ .../src/components/settings/SliderControl.tsx | 51 +++ .../src/components/settings/ToggleControl.tsx | 38 ++ .../src/components/settings/index.ts | 4 + .../graph-viewer/src/hooks/useForceLayout.ts | 91 +++- packages/graph-viewer/src/lib/types.ts | 77 ++++ 9 files changed, 903 insertions(+), 105 deletions(-) create mode 100644 packages/graph-viewer/src/components/settings/SettingsPanel.tsx create mode 100644 packages/graph-viewer/src/components/settings/SettingsSection.tsx create mode 100644 packages/graph-viewer/src/components/settings/SliderControl.tsx create mode 100644 packages/graph-viewer/src/components/settings/ToggleControl.tsx create mode 100644 packages/graph-viewer/src/components/settings/index.ts diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 1ec052a..133a51a 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -1,21 +1,35 @@ import { useState, useCallback } from 'react' +import { Settings } from 'lucide-react' // Build version - update this when making significant changes -const BUILD_VERSION = '2024-12-11-masterhand-v7' +const BUILD_VERSION = '2024-12-23-obsidian-settings-v1' import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' import { useGraphSnapshot } from './hooks/useGraphData' import { useAuth } from './hooks/useAuth' import { GraphCanvas } from './components/GraphCanvas' import { Inspector } from './components/Inspector' import { SearchBar } from './components/SearchBar' -import { FilterPanel } from './components/FilterPanel' import { TokenPrompt } from './components/TokenPrompt' import { StatsBar } from './components/StatsBar' import { GestureDebugOverlay } from './components/GestureDebugOverlay' import { Hand2DOverlay } from './components/Hand2DOverlay' import { HandControlOverlay } from './components/HandControlOverlay' +import { SettingsPanel } from './components/settings' import { useHandLockAndGrab } from './hooks/useHandLockAndGrab' -import type { GraphNode, FilterState } from './lib/types' +import type { + GraphNode, + FilterState, + ForceConfig, + DisplayConfig, + ClusterConfig, + RelationshipVisibility, +} from './lib/types' +import { + DEFAULT_FORCE_CONFIG, + DEFAULT_DISPLAY_CONFIG, + DEFAULT_CLUSTER_CONFIG, + DEFAULT_RELATIONSHIP_VISIBILITY, +} from './lib/types' import type { GestureState } from './hooks/useHandGestures' // Default gesture state for when not tracking @@ -87,6 +101,7 @@ export default function App() { const [gestureControlEnabled, setGestureControlEnabled] = useState(false) const [debugOverlayVisible, setDebugOverlayVisible] = useState(false) const [performanceMode, setPerformanceMode] = useState(false) + const [settingsPanelOpen, setSettingsPanelOpen] = useState(false) const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) const [trackingInfo, setTrackingInfo] = useState<{ source: 'mediapipe' | 'iphone' @@ -105,19 +120,36 @@ export default function App() { bridgeIps: [], phonePort: null, }) + + // Filter state const [filters, setFilters] = useState({ types: [], minImportance: 0, maxNodes: 500, }) + // Force configuration state + const [forceConfig, setForceConfig] = useState(DEFAULT_FORCE_CONFIG) + + // Display configuration state + const [displayConfig, setDisplayConfig] = useState(DEFAULT_DISPLAY_CONFIG) + + // Clustering configuration state + const [clusterConfig, setClusterConfig] = useState(DEFAULT_CLUSTER_CONFIG) + + // Relationship visibility state + const [relationshipVisibility, setRelationshipVisibility] = useState( + DEFAULT_RELATIONSHIP_VISIBILITY + ) + + // Reheat callback - will be set by GraphCanvas + const [reheatFn, setReheatFn] = useState<(() => void) | null>(null) + const handleGestureStateChange = useCallback((state: GestureState) => { setGestureState(state) }, []) const { lock: handLock } = useHandLockAndGrab(gestureState, gestureControlEnabled) - // Note: GraphCanvas owns the actual tracking source selection via URL params. - // We mirror it here via onTrackingInfoChange so overlays can show accurate status. const { data, isLoading, error, refetch } = useGraphSnapshot({ limit: filters.maxNodes, @@ -142,6 +174,30 @@ export default function App() { setFilters(prev => ({ ...prev, ...newFilters })) }, []) + const handleForceConfigChange = useCallback((config: Partial) => { + setForceConfig(prev => ({ ...prev, ...config })) + }, []) + + const handleDisplayConfigChange = useCallback((config: Partial) => { + setDisplayConfig(prev => ({ ...prev, ...config })) + }, []) + + const handleClusterConfigChange = useCallback((config: Partial) => { + setClusterConfig(prev => ({ ...prev, ...config })) + }, []) + + const handleRelationshipVisibilityChange = useCallback((visibility: Partial) => { + setRelationshipVisibility(prev => ({ ...prev, ...visibility })) + }, []) + + const handleReheat = useCallback(() => { + reheatFn?.() + }, [reheatFn]) + + const handleResetForces = useCallback(() => { + setForceConfig(DEFAULT_FORCE_CONFIG) + }, []) + if (!isAuthenticated) { return } @@ -165,12 +221,6 @@ export default function App() { className="flex-1 max-w-xl" /> - - {/* Version indicator - helps verify deployment */} @@ -233,92 +283,135 @@ export default function App() { )} + + {/* Settings Panel Toggle */} + {/* Main Content */} - - {/* Graph Canvas */} - -
- {isLoading && ( -
-
-
- Loading memories... +
+ + {/* Graph Canvas */} + +
+ {isLoading && ( +
+
+
+ Loading memories... +
-
- )} - - {error && ( -
-
-
Connection Error
-
{(error as Error).message}
- + )} + + {error && ( +
+
+
Connection Error
+
{(error as Error).message}
+ +
-
- )} - - + )} - {/* 2D Hand Overlay (on top of canvas, life-size) */} - + - {/* Gesture Debug Overlay */} - + {/* 2D Hand Overlay (on top of canvas, life-size) */} + + + {/* Gesture Debug Overlay */} + + + {/* Hand Control Overlay (lock/grab metrics) */} + +
+ + + {/* Resize Handle */} + - {/* Hand Control Overlay (lock/grab metrics) */} - + setSelectedNode(null)} + onNavigate={handleNodeSelect} /> -
-
- - {/* Resize Handle */} - - - {/* Inspector Panel */} - - setSelectedNode(null)} - onNavigate={handleNodeSelect} - /> - -
+ + + + {/* Settings Panel (right-docked) */} + setSettingsPanelOpen(false)} + filters={filters} + onFiltersChange={handleFilterChange} + typeColors={data?.meta?.type_colors} + forceConfig={forceConfig} + onForceConfigChange={handleForceConfigChange} + onReheat={handleReheat} + onResetForces={handleResetForces} + displayConfig={displayConfig} + onDisplayConfigChange={handleDisplayConfigChange} + clusterConfig={clusterConfig} + onClusterConfigChange={handleClusterConfigChange} + relationshipVisibility={relationshipVisibility} + onRelationshipVisibilityChange={handleRelationshipVisibilityChange} + /> +
) } diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index a2cb8df..1ae3ebe 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -28,7 +28,15 @@ import { useIPhoneHandTracking } from '../hooks/useIPhoneHandTracking' import { useHandInteraction } from '../hooks/useHandInteraction' import { useHandLockAndGrab } from '../hooks/useHandLockAndGrab' import { ExpandedNodeView } from './ExpandedNodeView' -import type { GraphNode, GraphEdge, SimulationNode } from '../lib/types' +import type { + GraphNode, + GraphEdge, + SimulationNode, + ForceConfig, + DisplayConfig, + RelationshipVisibility, +} from '../lib/types' +import { DEFAULT_FORCE_CONFIG, DEFAULT_DISPLAY_CONFIG, DEFAULT_RELATIONSHIP_VISIBILITY } from '../lib/types' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' import { findNodeHit, type NodeSphere, type NodeHit } from '../hooks/useStablePointerRay' @@ -81,7 +89,11 @@ interface GraphCanvasProps { bridgeIps: string[] phonePort: number | null }) => void - performanceMode?: boolean // New prop for disabling post-processing + performanceMode?: boolean + forceConfig?: ForceConfig + displayConfig?: DisplayConfig + relationshipVisibility?: RelationshipVisibility + onReheatReady?: (reheat: () => void) => void } export function GraphCanvas({ @@ -96,6 +108,10 @@ export function GraphCanvas({ onGestureStateChange, onTrackingInfoChange, performanceMode = false, + forceConfig = DEFAULT_FORCE_CONFIG, + displayConfig = DEFAULT_DISPLAY_CONFIG, + relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, + onReheatReady, }: GraphCanvasProps) { // Determine tracking source const { source, iphoneUrl } = useTrackingSource() @@ -154,6 +170,10 @@ export function GraphCanvas({ gestureState={gestureState} gestureControlEnabled={gestureControlEnabled && gesturesActive} performanceMode={performanceMode} + forceConfig={forceConfig} + displayConfig={displayConfig} + relationshipVisibility={relationshipVisibility} + onReheatReady={onReheatReady} /> ) @@ -176,8 +196,22 @@ function Scene({ gestureState, gestureControlEnabled, performanceMode, + forceConfig = DEFAULT_FORCE_CONFIG, + displayConfig: _displayConfig = DEFAULT_DISPLAY_CONFIG, + relationshipVisibility: _relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, + onReheatReady, }: SceneProps) { - const { nodes: layoutNodes, isSimulating } = useForceLayout({ nodes, edges }) + // TODO: Use displayConfig and relationshipVisibility in future sprints + void _displayConfig + void _relationshipVisibility + const { nodes: layoutNodes, isSimulating, reheat } = useForceLayout({ nodes, edges, forceConfig }) + + // Expose reheat function to parent + useEffect(() => { + if (onReheatReady) { + onReheatReady(reheat) + } + }, [onReheatReady, reheat]) const [autoRotate, setAutoRotate] = useState(false) const groupRef = useRef(null) const controlsRef = useRef(null) diff --git a/packages/graph-viewer/src/components/settings/SettingsPanel.tsx b/packages/graph-viewer/src/components/settings/SettingsPanel.tsx new file mode 100644 index 0000000..138184e --- /dev/null +++ b/packages/graph-viewer/src/components/settings/SettingsPanel.tsx @@ -0,0 +1,405 @@ +import { X, RotateCcw, Zap } from 'lucide-react' +import { SettingsSection } from './SettingsSection' +import { SliderControl } from './SliderControl' +import { ToggleControl } from './ToggleControl' +import type { + ForceConfig, + DisplayConfig, + ClusterConfig, + ClusterMode, + RelationType, + RelationshipVisibility, + MemoryType, + FilterState, +} from '../../lib/types' + +// Relationship type metadata for display +const RELATIONSHIP_INFO: Record = { + RELATES_TO: { label: 'Relates To', color: '#94A3B8', style: 'dotted' }, + LEADS_TO: { label: 'Leads To', color: '#3B82F6', style: 'solid' }, + OCCURRED_BEFORE: { label: 'Occurred Before', color: '#6B7280', style: 'dashed' }, + PREFERS_OVER: { label: 'Prefers Over', color: '#8B5CF6', style: 'solid' }, + EXEMPLIFIES: { label: 'Exemplifies', color: '#10B981', style: 'dotted' }, + CONTRADICTS: { label: 'Contradicts', color: '#EF4444', style: 'dashed' }, + REINFORCES: { label: 'Reinforces', color: '#22C55E', style: 'dotted' }, + INVALIDATED_BY: { label: 'Invalidated By', color: '#F97316', style: 'dashed' }, + EVOLVED_INTO: { label: 'Evolved Into', color: '#06B6D4', style: 'solid' }, + DERIVED_FROM: { label: 'Derived From', color: '#A855F7', style: 'solid' }, + PART_OF: { label: 'Part Of', color: '#64748B', style: 'solid' }, +} + +const MEMORY_TYPES: MemoryType[] = [ + 'Decision', 'Pattern', 'Preference', 'Style', + 'Habit', 'Insight', 'Context', 'Memory', +] + +const CLUSTER_MODES: { value: ClusterMode; label: string }[] = [ + { value: 'none', label: 'None' }, + { value: 'type', label: 'By Type' }, + { value: 'tags', label: 'By Tags' }, + { value: 'semantic', label: 'Semantic' }, +] + +interface SettingsPanelProps { + isOpen: boolean + onClose: () => void + // Filter state + filters: FilterState + onFiltersChange: (filters: Partial) => void + typeColors?: Record + // Force configuration + forceConfig: ForceConfig + onForceConfigChange: (config: Partial) => void + onReheat: () => void + onResetForces: () => void + // Display settings + displayConfig: DisplayConfig + onDisplayConfigChange: (config: Partial) => void + // Clustering + clusterConfig: ClusterConfig + onClusterConfigChange: (config: Partial) => void + // Relationship visibility + relationshipVisibility: RelationshipVisibility + onRelationshipVisibilityChange: (visibility: Partial) => void +} + +export function SettingsPanel({ + isOpen, + onClose, + filters, + onFiltersChange, + typeColors = {}, + forceConfig, + onForceConfigChange, + onReheat, + onResetForces, + displayConfig, + onDisplayConfigChange, + clusterConfig, + onClusterConfigChange, + relationshipVisibility, + onRelationshipVisibilityChange, +}: SettingsPanelProps) { + if (!isOpen) return null + + const toggleType = (type: MemoryType) => { + const types = filters.types.includes(type) + ? filters.types.filter((t) => t !== type) + : [...filters.types, type] + onFiltersChange({ types }) + } + + return ( +
+ {/* Header */} +
+

Graph Settings

+ +
+ + {/* Scrollable content */} +
+ {/* Filters Section */} + + {/* Memory Types */} +
+ +
+ {MEMORY_TYPES.map((type) => { + const isSelected = filters.types.length === 0 || filters.types.includes(type) + const color = typeColors[type] || '#94A3B8' + return ( + + ) + })} +
+ {filters.types.length > 0 && ( + + )} +
+ + onFiltersChange({ minImportance: v })} + formatValue={(v) => v.toFixed(1)} + /> + +
+ +
+ {[100, 250, 500, 1000].map((n) => ( + + ))} +
+
+
+ + {/* Relationships Section */} + +
+ {(Object.keys(RELATIONSHIP_INFO) as RelationType[]).map((rel) => { + const info = RELATIONSHIP_INFO[rel] + const isVisible = relationshipVisibility[rel] + return ( + + ) + })} +
+
+ + {/* Display Section */} + + onDisplayConfigChange({ showLabels: v })} + /> + + {displayConfig.showLabels && ( + onDisplayConfigChange({ labelFadeDistance: v })} + formatValue={(v) => v.toFixed(0)} + /> + )} + + onDisplayConfigChange({ showArrows: v })} + description="Directional arrows on edges" + /> + + onDisplayConfigChange({ nodeSizeScale: v })} + formatValue={(v) => `${v.toFixed(1)}x`} + /> + + onDisplayConfigChange({ linkThickness: v })} + formatValue={(v) => v.toFixed(2)} + /> + + onDisplayConfigChange({ linkOpacity: v })} + formatValue={(v) => `${Math.round(v * 100)}%`} + /> + + + {/* Clustering Section */} + +
+ +
+ {CLUSTER_MODES.map(({ value, label }) => ( + + ))} +
+
+ + {clusterConfig.mode !== 'none' && ( + <> + onClusterConfigChange({ showBoundaries: v })} + description="Dotted circles around clusters" + /> + + onClusterConfigChange({ clusterStrength: v })} + formatValue={(v) => v.toFixed(2)} + /> + + )} +
+ + {/* Forces Section */} + + onForceConfigChange({ centerStrength: v })} + /> + + onForceConfigChange({ chargeStrength: v })} + formatValue={(v) => v.toFixed(0)} + /> + + onForceConfigChange({ linkStrength: v })} + /> + + onForceConfigChange({ linkDistance: v })} + formatValue={(v) => v.toFixed(0)} + /> + + onForceConfigChange({ collisionRadius: v })} + /> + +
+ + +
+
+
+
+ ) +} diff --git a/packages/graph-viewer/src/components/settings/SettingsSection.tsx b/packages/graph-viewer/src/components/settings/SettingsSection.tsx new file mode 100644 index 0000000..86c56b6 --- /dev/null +++ b/packages/graph-viewer/src/components/settings/SettingsSection.tsx @@ -0,0 +1,33 @@ +import { useState, ReactNode } from 'react' +import { ChevronDown } from 'lucide-react' + +interface SettingsSectionProps { + title: string + defaultOpen?: boolean + children: ReactNode +} + +export function SettingsSection({ title, defaultOpen = true, children }: SettingsSectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen) + + return ( +
+ + {isOpen && ( +
+ {children} +
+ )} +
+ ) +} diff --git a/packages/graph-viewer/src/components/settings/SliderControl.tsx b/packages/graph-viewer/src/components/settings/SliderControl.tsx new file mode 100644 index 0000000..0416388 --- /dev/null +++ b/packages/graph-viewer/src/components/settings/SliderControl.tsx @@ -0,0 +1,51 @@ +interface SliderControlProps { + label: string + value: number + min: number + max: number + step?: number + onChange: (value: number) => void + formatValue?: (value: number) => string +} + +export function SliderControl({ + label, + value, + min, + max, + step = 0.01, + onChange, + formatValue = (v) => v.toFixed(2), +}: SliderControlProps) { + return ( +
+
+ + {formatValue(value)} +
+ onChange(parseFloat(e.target.value))} + className="w-full h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer + [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:w-3 + [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full + [&::-webkit-slider-thumb]:bg-blue-500 + [&::-webkit-slider-thumb]:cursor-pointer + [&::-webkit-slider-thumb]:transition-transform + [&::-webkit-slider-thumb]:hover:scale-110 + [&::-moz-range-thumb]:w-3 + [&::-moz-range-thumb]:h-3 + [&::-moz-range-thumb]:rounded-full + [&::-moz-range-thumb]:bg-blue-500 + [&::-moz-range-thumb]:border-0 + [&::-moz-range-thumb]:cursor-pointer" + /> +
+ ) +} diff --git a/packages/graph-viewer/src/components/settings/ToggleControl.tsx b/packages/graph-viewer/src/components/settings/ToggleControl.tsx new file mode 100644 index 0000000..4d989dc --- /dev/null +++ b/packages/graph-viewer/src/components/settings/ToggleControl.tsx @@ -0,0 +1,38 @@ +interface ToggleControlProps { + label: string + checked: boolean + onChange: (checked: boolean) => void + description?: string +} + +export function ToggleControl({ label, checked, onChange, description }: ToggleControlProps) { + return ( +
+
+ + {description && ( + {description} + )} +
+ +
+ ) +} diff --git a/packages/graph-viewer/src/components/settings/index.ts b/packages/graph-viewer/src/components/settings/index.ts new file mode 100644 index 0000000..6381ed1 --- /dev/null +++ b/packages/graph-viewer/src/components/settings/index.ts @@ -0,0 +1,4 @@ +export { SettingsPanel } from './SettingsPanel' +export { SettingsSection } from './SettingsSection' +export { SliderControl } from './SliderControl' +export { ToggleControl } from './ToggleControl' diff --git a/packages/graph-viewer/src/hooks/useForceLayout.ts b/packages/graph-viewer/src/hooks/useForceLayout.ts index 13c3f7e..3270931 100644 --- a/packages/graph-viewer/src/hooks/useForceLayout.ts +++ b/packages/graph-viewer/src/hooks/useForceLayout.ts @@ -7,14 +7,19 @@ import { forceCollide, forceRadial, } from 'd3-force-3d' -import type { GraphNode, GraphEdge, SimulationNode, SimulationLink } from '../lib/types' +import type { + GraphNode, + GraphEdge, + SimulationNode, + SimulationLink, + ForceConfig, +} from '../lib/types' +import { DEFAULT_FORCE_CONFIG } from '../lib/types' interface UseForceLayoutOptions { nodes: GraphNode[] edges: GraphEdge[] - strength?: number - centerStrength?: number - collisionRadius?: number + forceConfig?: ForceConfig } interface LayoutState { @@ -25,24 +30,38 @@ interface LayoutState { export function useForceLayout({ nodes, edges, - strength = -100, - centerStrength = 0.05, - collisionRadius = 2, + forceConfig = DEFAULT_FORCE_CONFIG, }: UseForceLayoutOptions): LayoutState & { reheat: () => void } { const simulationRef = useRef | null>(null) const [layoutNodes, setLayoutNodes] = useState([]) const [isSimulating, setIsSimulating] = useState(false) + const simNodesRef = useRef([]) // Initialize simulation when nodes/edges change useEffect(() => { if (nodes.length === 0) { setLayoutNodes([]) + simNodesRef.current = [] return } // Create simulation nodes with initial positions const simNodes: SimulationNode[] = nodes.map((node, i) => { - // Use Fibonacci sphere for initial distribution + // Check if we have existing position for this node + const existing = simNodesRef.current.find((n) => n.id === node.id) + if (existing) { + return { + ...node, + x: existing.x, + y: existing.y, + z: existing.z, + vx: existing.vx || 0, + vy: existing.vy || 0, + vz: existing.vz || 0, + } + } + + // Use Fibonacci sphere for initial distribution of new nodes const phi = Math.acos(1 - (2 * (i + 0.5)) / nodes.length) const theta = Math.PI * (1 + Math.sqrt(5)) * i const radius = 50 + (1 - node.importance) * 100 // High importance = center @@ -58,6 +77,8 @@ export function useForceLayout({ } }) + simNodesRef.current = simNodes + // Create node lookup const nodeById = new Map(simNodes.map((n) => [n.id, n])) @@ -82,15 +103,18 @@ export function useForceLayout({ 'link', forceLink(links) .id((d: SimulationNode) => d.id) - .distance((d: SimulationLink) => 30 + (1 - d.strength) * 50) - .strength((d: SimulationLink) => d.strength * 0.5) + .distance((d: SimulationLink) => { + const baseDistance = forceConfig.linkDistance + return baseDistance + (1 - d.strength) * baseDistance + }) + .strength((d: SimulationLink) => d.strength * forceConfig.linkStrength) ) - .force('charge', forceManyBody().strength(strength)) - .force('center', forceCenter(0, 0, 0).strength(centerStrength)) + .force('charge', forceManyBody().strength(forceConfig.chargeStrength)) + .force('center', forceCenter(0, 0, 0).strength(forceConfig.centerStrength)) .force( 'collision', forceCollide() - .radius((d: SimulationNode) => d.radius * collisionRadius) + .radius((d: SimulationNode) => d.radius * forceConfig.collisionRadius) .strength(0.7) ) .force( @@ -123,7 +147,46 @@ export function useForceLayout({ return () => { simulation.stop() } - }, [nodes, edges, strength, centerStrength, collisionRadius]) + }, [nodes, edges]) // Note: forceConfig changes handled separately + + // Update forces when config changes (without resetting positions) + useEffect(() => { + const simulation = simulationRef.current + if (!simulation) return + + // Update charge force + const charge = simulation.force('charge') as ReturnType | undefined + if (charge) { + charge.strength(forceConfig.chargeStrength) + } + + // Update center force + const center = simulation.force('center') as ReturnType | undefined + if (center) { + center.strength(forceConfig.centerStrength) + } + + // Update collision force + const collision = simulation.force('collision') as ReturnType | undefined + if (collision) { + collision.radius((d: SimulationNode) => d.radius * forceConfig.collisionRadius) + } + + // Update link force + const link = simulation.force('link') as ReturnType | undefined + if (link) { + link + .distance((d: SimulationLink) => { + const baseDistance = forceConfig.linkDistance + return baseDistance + (1 - d.strength) * baseDistance + }) + .strength((d: SimulationLink) => d.strength * forceConfig.linkStrength) + } + + // Gently reheat to apply changes + simulation.alpha(0.3).restart() + setIsSimulating(true) + }, [forceConfig]) const reheat = useCallback(() => { if (simulationRef.current) { diff --git a/packages/graph-viewer/src/lib/types.ts b/packages/graph-viewer/src/lib/types.ts index 9e7f22f..6e97790 100644 --- a/packages/graph-viewer/src/lib/types.ts +++ b/packages/graph-viewer/src/lib/types.ts @@ -124,3 +124,80 @@ export interface SimulationLink { strength: number type: RelationType } + +// Force layout configuration +export interface ForceConfig { + centerStrength: number // 0.01 - 0.2, default 0.05 + chargeStrength: number // -200 to -50, default -100 + linkStrength: number // 0.1 - 1.0, default 0.5 + linkDistance: number // 20 - 100, default 50 + collisionRadius: number // 1.0 - 4.0, default 2.0 +} + +// Display settings +export interface DisplayConfig { + showLabels: boolean + labelFadeDistance: number // Distance at which labels start fading + showArrows: boolean + nodeSizeScale: number // Multiplier for node sizes + linkThickness: number // Base link thickness + linkOpacity: number // 0-1 +} + +// Clustering configuration +export type ClusterMode = 'type' | 'tags' | 'semantic' | 'none' + +export interface ClusterConfig { + mode: ClusterMode + showBoundaries: boolean + clusterStrength: number // Additional force pulling cluster members together +} + +// Relationship visibility +export type RelationshipVisibility = Record + +// Combined settings state +export interface GraphSettings { + forces: ForceConfig + display: DisplayConfig + clustering: ClusterConfig + relationshipVisibility: RelationshipVisibility +} + +// Default values +export const DEFAULT_FORCE_CONFIG: ForceConfig = { + centerStrength: 0.05, + chargeStrength: -100, + linkStrength: 0.5, + linkDistance: 50, + collisionRadius: 2.0, +} + +export const DEFAULT_DISPLAY_CONFIG: DisplayConfig = { + showLabels: true, + labelFadeDistance: 80, + showArrows: false, + nodeSizeScale: 1.0, + linkThickness: 1.0, + linkOpacity: 0.6, +} + +export const DEFAULT_CLUSTER_CONFIG: ClusterConfig = { + mode: 'type', + showBoundaries: false, + clusterStrength: 0.3, +} + +export const DEFAULT_RELATIONSHIP_VISIBILITY: RelationshipVisibility = { + RELATES_TO: true, + LEADS_TO: true, + OCCURRED_BEFORE: true, + PREFERS_OVER: true, + EXEMPLIFIES: true, + CONTRADICTS: true, + REINFORCES: true, + INVALIDATED_BY: true, + EVOLVED_INTO: true, + DERIVED_FROM: true, + PART_OF: true, +} From 2cb5b326c8a507f7c956105d13ef771183cd4078 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 21:32:14 +0000 Subject: [PATCH 27/47] feat(graph-viewer): add relationship-styled edges and display controls Sprint 2 implementation: - Add edge styles with distinct colors per relationship type - Causal (blue): LEADS_TO, EVOLVED_INTO, DERIVED_FROM - Temporal (orange/gray): OCCURRED_BEFORE, INVALIDATED_BY - Associative (green): EXEMPLIFIES, REINFORCES, RELATES_TO - Conflict (red): CONTRADICTS - Hierarchical (violet/slate): PREFERS_OVER, PART_OF - Filter edges by relationship type visibility - Apply link thickness and opacity from display settings - Apply node size scale from display settings - Toggle label visibility and configure fade distance Display settings now control: - Edge colors based on relationship type - Edge visibility per relationship type - Link thickness and opacity - Node size scaling - Label visibility and fade distance --- .../src/components/GraphCanvas.tsx | 88 +++++++--- packages/graph-viewer/src/lib/edgeStyles.ts | 163 ++++++++++++++++++ 2 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 packages/graph-viewer/src/lib/edgeStyles.ts diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 1ae3ebe..d7172b6 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -37,6 +37,7 @@ import type { RelationshipVisibility, } from '../lib/types' import { DEFAULT_FORCE_CONFIG, DEFAULT_DISPLAY_CONFIG, DEFAULT_RELATIONSHIP_VISIBILITY } from '../lib/types' +import { getEdgeStyle } from '../lib/edgeStyles' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' import { findNodeHit, type NodeSphere, type NodeHit } from '../hooks/useStablePointerRay' @@ -201,9 +202,6 @@ function Scene({ relationshipVisibility: _relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, onReheatReady, }: SceneProps) { - // TODO: Use displayConfig and relationshipVisibility in future sprints - void _displayConfig - void _relationshipVisibility const { nodes: layoutNodes, isSimulating, reheat } = useForceLayout({ nodes, edges, forceConfig }) // Expose reheat function to parent @@ -574,6 +572,9 @@ function Scene({ nodeById={nodeById} selectedNode={selectedNode} connectedIds={connectedIds} + relationshipVisibility={_relationshipVisibility} + linkThickness={_displayConfig.linkThickness} + linkOpacity={_displayConfig.linkOpacity} /> {/* Instanced nodes - single draw call for all nodes */} @@ -586,16 +587,20 @@ function Scene({ connectedIds={connectedIds} onNodeSelect={onNodeSelect} onNodeHover={onNodeHover} + nodeSizeScale={_displayConfig.nodeSizeScale} /> {/* LOD Labels - only for selected/hovered/nearby nodes */} - + {_displayConfig.showLabels && ( + + )} {/* Expanded Node View - shows when a node is selected via hand */} {expandedNode && ( @@ -672,28 +677,45 @@ function Scene({ /** * Batched edge rendering using LineSegments - * All edges rendered in a single draw call + * All edges rendered in a single draw call with relationship-based styling */ interface BatchedEdgesProps { edges: GraphEdge[] nodeById: Map selectedNode: GraphNode | null connectedIds: Set + relationshipVisibility: RelationshipVisibility + linkThickness: number + linkOpacity: number } -function BatchedEdges({ edges, nodeById, selectedNode, connectedIds }: BatchedEdgesProps) { +function BatchedEdges({ + edges, + nodeById, + selectedNode, + connectedIds, + relationshipVisibility, + linkThickness, + linkOpacity, +}: BatchedEdgesProps) { const lineRef = useRef(null) - // Create geometry with all edge vertices - const { positions, colors } = useMemo(() => { + // Filter edges by visibility and create geometry + const { positions, colors, visibleCount } = useMemo(() => { const positions: number[] = [] const colors: number[] = [] + let visibleCount = 0 edges.forEach((edge) => { + // Filter by relationship visibility + if (!relationshipVisibility[edge.type]) return + const sourceNode = nodeById.get(edge.source) const targetNode = nodeById.get(edge.target) if (!sourceNode || !targetNode) return + visibleCount++ + const isHighlighted = selectedNode && (edge.source === selectedNode.id || edge.target === selectedNode.id) @@ -708,9 +730,19 @@ function BatchedEdges({ edges, nodeById, selectedNode, connectedIds }: BatchedEd // Target vertex positions.push(targetNode.x ?? 0, targetNode.y ?? 0, targetNode.z ?? 0) - // Parse edge color - const color = new THREE.Color(edge.color) - const alpha = isDimmed ? 0.05 : isHighlighted ? 0.8 : 0.3 + // Get style for this edge type + const style = getEdgeStyle(edge.type) + + // Use style color instead of edge.color + const color = new THREE.Color(style.color) + + // Calculate alpha based on state and style + let alpha = style.opacity * linkOpacity + if (isDimmed) { + alpha *= 0.1 + } else if (isHighlighted) { + alpha = Math.min(1, alpha * 1.5) + } // Apply alpha to color (approximate, since LineBasicMaterial doesn't support per-vertex alpha) const r = color.r * alpha @@ -724,8 +756,9 @@ function BatchedEdges({ edges, nodeById, selectedNode, connectedIds }: BatchedEd return { positions: new Float32Array(positions), colors: new Float32Array(colors), + visibleCount, } - }, [edges, nodeById, selectedNode, connectedIds]) + }, [edges, nodeById, selectedNode, connectedIds, relationshipVisibility, linkOpacity]) // Update geometry when positions/colors change useEffect(() => { @@ -739,10 +772,17 @@ function BatchedEdges({ edges, nodeById, selectedNode, connectedIds }: BatchedEd geometry.computeBoundingSphere() }, [positions, colors]) + if (visibleCount === 0) return null + return ( - + ) } @@ -760,6 +800,7 @@ interface InstancedNodesProps { connectedIds: Set onNodeSelect: (node: GraphNode | null) => void onNodeHover: (node: GraphNode | null) => void + nodeSizeScale?: number } function InstancedNodes({ @@ -771,6 +812,7 @@ function InstancedNodes({ connectedIds, onNodeSelect, onNodeHover, + nodeSizeScale = 1.0, }: InstancedNodesProps) { const meshRef = useRef(null) const { camera, raycaster, pointer } = useThree() @@ -838,9 +880,9 @@ function InstancedNodes({ finalScale *= pulse } - // Set position and scale + // Set position and scale (apply nodeSizeScale) tempPosition.set(node.x ?? 0, node.y ?? 0, node.z ?? 0) - tempScale.setScalar(node.radius * finalScale) + tempScale.setScalar(node.radius * finalScale * nodeSizeScale) tempMatrix.compose(tempPosition, tempQuaternion, tempScale) mesh.setMatrixAt(i, tempMatrix) @@ -927,6 +969,7 @@ interface LODLabelsProps { hoveredNode: GraphNode | null searchTerm: string matchingIds: Set + labelFadeDistance?: number } function LODLabels({ @@ -935,6 +978,7 @@ function LODLabels({ hoveredNode, searchTerm, matchingIds, + labelFadeDistance = LABEL_DISTANCE_THRESHOLD, }: LODLabelsProps) { const { camera } = useThree() const [visibleNodes, setVisibleNodes] = useState([]) @@ -964,7 +1008,7 @@ function LODLabels({ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) // Include search matches and nearby nodes - if (distance < LABEL_DISTANCE_THRESHOLD || isSearchMatch) { + if (distance < labelFadeDistance || isSearchMatch) { nearbyNodes.push({ node, distance }) } }) diff --git a/packages/graph-viewer/src/lib/edgeStyles.ts b/packages/graph-viewer/src/lib/edgeStyles.ts new file mode 100644 index 0000000..99cbd02 --- /dev/null +++ b/packages/graph-viewer/src/lib/edgeStyles.ts @@ -0,0 +1,163 @@ +import type { RelationType } from './types' + +export interface EdgeStyle { + color: string // Hex color + opacity: number // 0-1 + width: number // Line width multiplier + dashPattern: number[] | null // [dashSize, gapSize] or null for solid + arrow: boolean // Whether to show directional arrow + category: 'causal' | 'temporal' | 'associative' | 'conflict' | 'hierarchical' + label: string // Human-readable name +} + +/** + * Edge styles by relationship type + * Organized by semantic category for visual coherence + */ +export const EDGE_STYLES: Record = { + // Causal/Flow relationships (solid, bold, directional) + LEADS_TO: { + color: '#3B82F6', // Blue + opacity: 0.8, + width: 2.0, + dashPattern: null, + arrow: true, + category: 'causal', + label: 'Leads To', + }, + EVOLVED_INTO: { + color: '#06B6D4', // Cyan + opacity: 0.7, + width: 1.5, + dashPattern: null, + arrow: true, + category: 'causal', + label: 'Evolved Into', + }, + DERIVED_FROM: { + color: '#A855F7', // Purple + opacity: 0.7, + width: 1.5, + dashPattern: null, + arrow: true, + category: 'causal', + label: 'Derived From', + }, + + // Temporal relationships (dashed, directional) + OCCURRED_BEFORE: { + color: '#6B7280', // Gray + opacity: 0.5, + width: 1.0, + dashPattern: [4, 2], + arrow: true, + category: 'temporal', + label: 'Occurred Before', + }, + INVALIDATED_BY: { + color: '#F97316', // Orange + opacity: 0.6, + width: 1.5, + dashPattern: [4, 2], + arrow: true, + category: 'temporal', + label: 'Invalidated By', + }, + + // Associative relationships (dotted, bidirectional) + RELATES_TO: { + color: '#94A3B8', // Slate + opacity: 0.4, + width: 1.0, + dashPattern: [2, 2], + arrow: false, + category: 'associative', + label: 'Relates To', + }, + EXEMPLIFIES: { + color: '#10B981', // Emerald + opacity: 0.6, + width: 1.5, + dashPattern: [2, 2], + arrow: false, + category: 'associative', + label: 'Exemplifies', + }, + REINFORCES: { + color: '#22C55E', // Green + opacity: 0.6, + width: 1.5, + dashPattern: [2, 2], + arrow: false, + category: 'associative', + label: 'Reinforces', + }, + + // Conflict relationships (red, prominent) + CONTRADICTS: { + color: '#EF4444', // Red + opacity: 0.7, + width: 2.0, + dashPattern: [6, 3], + arrow: false, + category: 'conflict', + label: 'Contradicts', + }, + + // Preference/Hierarchy (solid, thinner) + PREFERS_OVER: { + color: '#8B5CF6', // Violet + opacity: 0.6, + width: 1.0, + dashPattern: null, + arrow: true, + category: 'hierarchical', + label: 'Prefers Over', + }, + PART_OF: { + color: '#64748B', // Slate darker + opacity: 0.5, + width: 1.0, + dashPattern: null, + arrow: true, + category: 'hierarchical', + label: 'Part Of', + }, +} + +/** + * Get style for a relationship type with fallback + */ +export function getEdgeStyle(type: RelationType): EdgeStyle { + return EDGE_STYLES[type] || EDGE_STYLES.RELATES_TO +} + +/** + * Category colors for grouping in UI + */ +export const CATEGORY_COLORS: Record = { + causal: '#3B82F6', + temporal: '#F97316', + associative: '#10B981', + conflict: '#EF4444', + hierarchical: '#8B5CF6', +} + +/** + * Get edges grouped by category for UI display + */ +export function getEdgesByCategory(): Record { + const groups: Record = { + causal: [], + temporal: [], + associative: [], + conflict: [], + hierarchical: [], + } + + for (const [type, style] of Object.entries(EDGE_STYLES)) { + groups[style.category].push(type as RelationType) + } + + return groups +} From 1e2c541fd40575430b751431825fcadda6283192 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 21:55:10 +0000 Subject: [PATCH 28/47] feat(graph-viewer): add clustering with visual boundaries - Add useClusterDetection hook for detecting clusters by type, tags, or semantic similarity - Add ClusterBoundaries component rendering dotted spheres around clusters - Integrate clustering config through GraphCanvas to Scene - Support three clustering modes: type (memory type), tags (first tag), semantic (connected components) - Boundaries gently rotate for visual interest - Fibonacci sphere point distribution for even dotted effect --- packages/graph-viewer/src/App.tsx | 2 + .../src/components/ClusterBoundaries.tsx | 117 ++++++++++++ .../src/components/GraphCanvas.tsx | 47 ++++- .../src/hooks/useClusterDetection.ts | 179 ++++++++++++++++++ 4 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 packages/graph-viewer/src/components/ClusterBoundaries.tsx create mode 100644 packages/graph-viewer/src/hooks/useClusterDetection.ts diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 133a51a..fa224a5 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -347,7 +347,9 @@ export default function App() { performanceMode={performanceMode} forceConfig={forceConfig} displayConfig={displayConfig} + clusterConfig={clusterConfig} relationshipVisibility={relationshipVisibility} + typeColors={data?.meta?.type_colors} onReheatReady={setReheatFn} /> diff --git a/packages/graph-viewer/src/components/ClusterBoundaries.tsx b/packages/graph-viewer/src/components/ClusterBoundaries.tsx new file mode 100644 index 0000000..70bd234 --- /dev/null +++ b/packages/graph-viewer/src/components/ClusterBoundaries.tsx @@ -0,0 +1,117 @@ +import { useMemo, useRef } from 'react' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import type { Cluster } from '../hooks/useClusterDetection' + +interface ClusterBoundariesProps { + clusters: Cluster[] + visible: boolean + opacity?: number +} + +// Generate points on a sphere surface for dotted effect +function generateSpherePoints(radius: number, count: number): Float32Array { + const positions = new Float32Array(count * 3) + + // Fibonacci sphere distribution for even spacing + const goldenRatio = (1 + Math.sqrt(5)) / 2 + const angleIncrement = Math.PI * 2 * goldenRatio + + for (let i = 0; i < count; i++) { + const t = i / count + const inclination = Math.acos(1 - 2 * t) + const azimuth = angleIncrement * i + + const x = Math.sin(inclination) * Math.cos(azimuth) * radius + const y = Math.sin(inclination) * Math.sin(azimuth) * radius + const z = Math.cos(inclination) * radius + + positions[i * 3] = x + positions[i * 3 + 1] = y + positions[i * 3 + 2] = z + } + + return positions +} + +/** + * A single cluster boundary sphere made of points + */ +function ClusterBoundary({ + cluster, + opacity = 0.3, +}: { + cluster: Cluster + opacity?: number +}) { + const pointsRef = useRef(null) + + // Number of points scales with radius + const pointCount = Math.max(100, Math.floor(cluster.radius * 8)) + + const positions = useMemo(() => { + return generateSpherePoints(cluster.radius, pointCount) + }, [cluster.radius, pointCount]) + + // Gentle rotation for visual interest + useFrame((_, delta) => { + if (pointsRef.current) { + pointsRef.current.rotation.y += delta * 0.05 + pointsRef.current.rotation.x += delta * 0.02 + } + }) + + // Parse color to THREE.Color + const color = useMemo(() => { + return new THREE.Color(cluster.color) + }, [cluster.color]) + + return ( + + + + + + + ) +} + +/** + * Renders dotted sphere boundaries around detected clusters + */ +export function ClusterBoundaries({ + clusters, + visible, + opacity = 0.3, +}: ClusterBoundariesProps) { + if (!visible || clusters.length === 0) { + return null + } + + return ( + + {clusters.map((cluster) => ( + + ))} + + ) +} diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index d7172b6..8275e5b 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -34,9 +34,12 @@ import type { SimulationNode, ForceConfig, DisplayConfig, + ClusterConfig, RelationshipVisibility, } from '../lib/types' -import { DEFAULT_FORCE_CONFIG, DEFAULT_DISPLAY_CONFIG, DEFAULT_RELATIONSHIP_VISIBILITY } from '../lib/types' +import { DEFAULT_FORCE_CONFIG, DEFAULT_DISPLAY_CONFIG, DEFAULT_CLUSTER_CONFIG, DEFAULT_RELATIONSHIP_VISIBILITY } from '../lib/types' +import { useClusterDetection } from '../hooks/useClusterDetection' +import { ClusterBoundaries } from './ClusterBoundaries' import { getEdgeStyle } from '../lib/edgeStyles' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' import { findNodeHit, type NodeSphere, type NodeHit } from '../hooks/useStablePointerRay' @@ -93,7 +96,9 @@ interface GraphCanvasProps { performanceMode?: boolean forceConfig?: ForceConfig displayConfig?: DisplayConfig + clusterConfig?: ClusterConfig relationshipVisibility?: RelationshipVisibility + typeColors?: Record onReheatReady?: (reheat: () => void) => void } @@ -111,7 +116,9 @@ export function GraphCanvas({ performanceMode = false, forceConfig = DEFAULT_FORCE_CONFIG, displayConfig = DEFAULT_DISPLAY_CONFIG, + clusterConfig = DEFAULT_CLUSTER_CONFIG, relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, + typeColors = {}, onReheatReady, }: GraphCanvasProps) { // Determine tracking source @@ -173,14 +180,16 @@ export function GraphCanvas({ performanceMode={performanceMode} forceConfig={forceConfig} displayConfig={displayConfig} + clusterConfig={clusterConfig} relationshipVisibility={relationshipVisibility} + typeColors={typeColors} onReheatReady={onReheatReady} /> ) } -interface SceneProps extends Omit { +interface SceneProps extends Omit { gestureState: GestureState gestureControlEnabled: boolean performanceMode: boolean @@ -198,12 +207,22 @@ function Scene({ gestureControlEnabled, performanceMode, forceConfig = DEFAULT_FORCE_CONFIG, - displayConfig: _displayConfig = DEFAULT_DISPLAY_CONFIG, - relationshipVisibility: _relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, + displayConfig = DEFAULT_DISPLAY_CONFIG, + clusterConfig = DEFAULT_CLUSTER_CONFIG, + relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, + typeColors = {}, onReheatReady, }: SceneProps) { const { nodes: layoutNodes, isSimulating, reheat } = useForceLayout({ nodes, edges, forceConfig }) + // Cluster detection + const clusters = useClusterDetection({ + nodes: layoutNodes, + edges, + mode: clusterConfig.mode, + typeColors, + }) + // Expose reheat function to parent useEffect(() => { if (onReheatReady) { @@ -566,15 +585,23 @@ function Scene({ {/* Graph content */} + {/* Batched edges - single draw call for all edges */} + {/* Cluster boundaries (rendered behind edges) */} + + {/* Batched edges - single draw call for all edges */} {/* Instanced nodes - single draw call for all nodes */} @@ -587,17 +614,17 @@ function Scene({ connectedIds={connectedIds} onNodeSelect={onNodeSelect} onNodeHover={onNodeHover} - nodeSizeScale={_displayConfig.nodeSizeScale} + nodeSizeScale={displayConfig.nodeSizeScale} /> {/* LOD Labels - only for selected/hovered/nearby nodes */} - {_displayConfig.showLabels && ( + {displayConfig.showLabels && ( )} diff --git a/packages/graph-viewer/src/hooks/useClusterDetection.ts b/packages/graph-viewer/src/hooks/useClusterDetection.ts new file mode 100644 index 0000000..0dd506a --- /dev/null +++ b/packages/graph-viewer/src/hooks/useClusterDetection.ts @@ -0,0 +1,179 @@ +import { useMemo } from 'react' +import type { SimulationNode, ClusterMode, GraphEdge } from '../lib/types' + +export interface Cluster { + id: string + label: string + color: string + nodeIds: Set + // Computed from node positions + centroid: { x: number; y: number; z: number } + radius: number +} + +interface UseClusterDetectionOptions { + nodes: SimulationNode[] + edges: GraphEdge[] + mode: ClusterMode + typeColors?: Record +} + +// Generate consistent colors for arbitrary cluster keys +function hashColor(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + const hue = Math.abs(hash) % 360 + return `hsl(${hue}, 60%, 50%)` +} + +/** + * Detect clusters based on the selected mode + * Returns cluster assignments and computed boundaries + */ +export function useClusterDetection({ + nodes, + edges, + mode, + typeColors = {}, +}: UseClusterDetectionOptions): Cluster[] { + return useMemo(() => { + if (mode === 'none' || nodes.length === 0) { + return [] + } + + // Group nodes by cluster key + const nodeGroups = new Map() + + if (mode === 'type') { + // Group by memory type + for (const node of nodes) { + const key = node.type + if (!nodeGroups.has(key)) { + nodeGroups.set(key, []) + } + nodeGroups.get(key)!.push(node) + } + } else if (mode === 'tags') { + // Group by primary tag (first tag) + // Nodes with the same first tag belong to the same cluster + for (const node of nodes) { + const key = node.tags[0] || 'untagged' + if (!nodeGroups.has(key)) { + nodeGroups.set(key, []) + } + nodeGroups.get(key)!.push(node) + } + } else if (mode === 'semantic') { + // Group by connected components using edges + // Nodes connected by strong relationships form clusters + const visited = new Set() + const nodeById = new Map(nodes.map(n => [n.id, n])) + + // Build adjacency list from edges with strength > 0.5 + const adj = new Map() + for (const edge of edges) { + if (edge.strength >= 0.5) { + // GraphEdge source/target are always strings + const source = edge.source + const target = edge.target + + if (!adj.has(source)) adj.set(source, []) + if (!adj.has(target)) adj.set(target, []) + adj.get(source)!.push(target) + adj.get(target)!.push(source) + } + } + + // Find connected components via BFS + let clusterIndex = 0 + for (const node of nodes) { + if (visited.has(node.id)) continue + + const queue = [node.id] + const component: SimulationNode[] = [] + + while (queue.length > 0) { + const id = queue.shift()! + if (visited.has(id)) continue + visited.add(id) + + const n = nodeById.get(id) + if (n) component.push(n) + + const neighbors = adj.get(id) || [] + for (const neighborId of neighbors) { + if (!visited.has(neighborId) && nodeById.has(neighborId)) { + queue.push(neighborId) + } + } + } + + if (component.length > 0) { + const key = `cluster-${clusterIndex++}` + nodeGroups.set(key, component) + } + } + } + + // Convert groups to Cluster objects with computed centroids + const clusters: Cluster[] = [] + + for (const [key, groupNodes] of nodeGroups) { + if (groupNodes.length < 2) continue // Skip single-node clusters + + // Calculate centroid + let cx = 0, cy = 0, cz = 0 + for (const node of groupNodes) { + cx += node.x || 0 + cy += node.y || 0 + cz += node.z || 0 + } + cx /= groupNodes.length + cy /= groupNodes.length + cz /= groupNodes.length + + // Calculate radius (max distance from centroid + padding) + let maxDist = 0 + for (const node of groupNodes) { + const dx = (node.x || 0) - cx + const dy = (node.y || 0) - cy + const dz = (node.z || 0) - cz + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) + maxDist = Math.max(maxDist, dist) + } + + // Determine color + let color: string + if (mode === 'type' && typeColors[key]) { + color = typeColors[key] + } else { + color = hashColor(key) + } + + clusters.push({ + id: key, + label: key, + color, + nodeIds: new Set(groupNodes.map(n => n.id)), + centroid: { x: cx, y: cy, z: cz }, + radius: maxDist + 15, // Add padding for visual clarity + }) + } + + return clusters + }, [nodes, edges, mode, typeColors]) +} + +/** + * Get cluster assignment for a node + */ +export function getNodeCluster(nodeId: string, clusters: Cluster[]): Cluster | null { + for (const cluster of clusters) { + if (cluster.nodeIds.has(nodeId)) { + return cluster + } + } + return null +} From 921487045cf3a9b4cf6baac55fc9e33ca9c0b50d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 21:57:44 +0000 Subject: [PATCH 29/47] feat(graph-viewer): add selection focus mode with highlights - Add SelectionHighlight component with glowing ring around selected node - Add ConnectedPathsHighlight with flowing particles along edges - Pulsing animation on selection ring for visual feedback - Three-ring display (XY and XZ planes) for 3D depth perception - Particles flow from selected node to connected nodes - Track selected node layout position for accurate highlight placement --- .../src/components/GraphCanvas.tsx | 35 +++ .../src/components/SelectionHighlight.tsx | 233 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 packages/graph-viewer/src/components/SelectionHighlight.tsx diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 8275e5b..9a7f096 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -40,6 +40,7 @@ import type { import { DEFAULT_FORCE_CONFIG, DEFAULT_DISPLAY_CONFIG, DEFAULT_CLUSTER_CONFIG, DEFAULT_RELATIONSHIP_VISIBILITY } from '../lib/types' import { useClusterDetection } from '../hooks/useClusterDetection' import { ClusterBoundaries } from './ClusterBoundaries' +import { SelectionHighlight, ConnectedPathsHighlight } from './SelectionHighlight' import { getEdgeStyle } from '../lib/edgeStyles' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' import { findNodeHit, type NodeSphere, type NodeHit } from '../hooks/useStablePointerRay' @@ -467,6 +468,23 @@ function Scene({ return ids }, [selectedNode, edges]) + // Get connected nodes for selection highlight + const connectedNodes = useMemo(() => { + if (!selectedNode) return [] + const ids = new Set() + edges.forEach((e) => { + if (e.source === selectedNode.id) ids.add(e.target) + if (e.target === selectedNode.id) ids.add(e.source) + }) + return layoutNodes.filter(n => ids.has(n.id)) + }, [selectedNode, edges, layoutNodes]) + + // Get selected node from layout (with current position) + const selectedLayoutNode = useMemo(() => { + if (!selectedNode) return null + return layoutNodes.find(n => n.id === selectedNode.id) ?? null + }, [selectedNode, layoutNodes]) + // Get expanded node and its connections const expandedNode = useMemo(() => { if (!expandedNodeId) return null @@ -617,6 +635,23 @@ function Scene({ nodeSizeScale={displayConfig.nodeSizeScale} /> + {/* Selection highlight - glowing ring around selected node */} + {selectedLayoutNode && ( + + )} + + {/* Connected paths highlight - particles flowing to connected nodes */} + {selectedNode && connectedNodes.length > 0 && ( + + )} + {/* LOD Labels - only for selected/hovered/nearby nodes */} {displayConfig.showLabels && ( (null) + const glowRef = useRef(null) + + // Ring geometry + const ringGeometry = useMemo(() => { + return new THREE.RingGeometry(innerRadius, outerRadius, 32) + }, [innerRadius, outerRadius]) + + // Pulsing animation + useFrame((state) => { + if (!node || !ringRef.current || !glowRef.current) return + + const t = state.clock.elapsedTime + + // Pulse opacity + const pulse = 0.6 + Math.sin(t * 2) * 0.2 + const material = ringRef.current.material as THREE.MeshBasicMaterial + material.opacity = pulse + + // Slow rotation + ringRef.current.rotation.z = t * 0.3 + + // Glow pulse + const glowMaterial = glowRef.current.material as THREE.MeshBasicMaterial + glowMaterial.opacity = 0.3 + Math.sin(t * 3) * 0.1 + glowRef.current.scale.setScalar(1 + Math.sin(t * 2) * 0.1) + }) + + if (!node) return null + + const nodeColor = color || node.color || '#3B82F6' + const nodeRadius = node.radius || 3 + + return ( + + {/* Inner glow sphere */} + + + + + + {/* Selection ring - XY plane */} + + + + + + {/* Selection ring - XZ plane */} + + + + + + ) +} + +interface ConnectedPathsHighlightProps { + selectedNode: GraphNode | null + connectedNodes: SimulationNode[] + color?: string +} + +/** + * Highlights paths from selected node to connected nodes + * Creates animated flowing particles along the edges + */ +export function ConnectedPathsHighlight({ + selectedNode, + connectedNodes, + color, +}: ConnectedPathsHighlightProps) { + const particlesRef = useRef(null) + + // Generate particle positions along paths + const { positions, colors } = useMemo(() => { + if (!selectedNode || connectedNodes.length === 0) { + return { positions: new Float32Array(0), colors: new Float32Array(0) } + } + + const particlesPerPath = 5 + const totalParticles = connectedNodes.length * particlesPerPath + const positions = new Float32Array(totalParticles * 3) + const colors = new Float32Array(totalParticles * 3) + + const selectedPos = { + x: (selectedNode as SimulationNode).x || 0, + y: (selectedNode as SimulationNode).y || 0, + z: (selectedNode as SimulationNode).z || 0, + } + + connectedNodes.forEach((node, nodeIndex) => { + const targetPos = { + x: node.x || 0, + y: node.y || 0, + z: node.z || 0, + } + + const baseColor = new THREE.Color(color || selectedNode.color || '#3B82F6') + + for (let i = 0; i < particlesPerPath; i++) { + const idx = (nodeIndex * particlesPerPath + i) * 3 + const t = (i + 1) / (particlesPerPath + 1) + + // Interpolate position + positions[idx] = selectedPos.x + (targetPos.x - selectedPos.x) * t + positions[idx + 1] = selectedPos.y + (targetPos.y - selectedPos.y) * t + positions[idx + 2] = selectedPos.z + (targetPos.z - selectedPos.z) * t + + // Color with fade + const fade = 1 - Math.abs(t - 0.5) * 0.5 + colors[idx] = baseColor.r * fade + colors[idx + 1] = baseColor.g * fade + colors[idx + 2] = baseColor.b * fade + } + }) + + return { positions, colors } + }, [selectedNode, connectedNodes, color]) + + // Animate particles flowing along paths + useFrame((state) => { + if (!particlesRef.current || !selectedNode || connectedNodes.length === 0) return + + const t = state.clock.elapsedTime + const geometry = particlesRef.current.geometry + const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute + + if (!positionAttr || positionAttr.count === 0) return + + const selectedPos = { + x: (selectedNode as SimulationNode).x || 0, + y: (selectedNode as SimulationNode).y || 0, + z: (selectedNode as SimulationNode).z || 0, + } + + const particlesPerPath = 5 + + connectedNodes.forEach((node, nodeIndex) => { + const targetPos = { + x: node.x || 0, + y: node.y || 0, + z: node.z || 0, + } + + for (let i = 0; i < particlesPerPath; i++) { + const idx = nodeIndex * particlesPerPath + i + // Flow along path with offset per particle + const baseT = (i + 1) / (particlesPerPath + 1) + const flowT = (baseT + (t * 0.5) % 1) % 1 + + positionAttr.setXYZ( + idx, + selectedPos.x + (targetPos.x - selectedPos.x) * flowT, + selectedPos.y + (targetPos.y - selectedPos.y) * flowT, + selectedPos.z + (targetPos.z - selectedPos.z) * flowT + ) + } + }) + + positionAttr.needsUpdate = true + }) + + if (!selectedNode || connectedNodes.length === 0 || positions.length === 0) { + return null + } + + return ( + + + + + + + + ) +} From bfaa092b38058164301ce90877a7b1477c92ebb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 21:59:59 +0000 Subject: [PATCH 30/47] feat(graph-viewer): add keyboard navigation - Add useKeyboardNavigation hook for Obsidian-style keyboard shortcuts - Arrow keys navigate between nodes spatially (up/down/left/right) - Shift+Arrow up/down for Z-axis navigation (forward/backward) - Tab/Shift+Tab cycles through nodes sequentially - Escape to deselect, R to reheat, L to toggle labels - Comma to toggle settings panel - Question mark shows help in console - Ignores input when focus is in text fields --- packages/graph-viewer/src/App.tsx | 19 ++ .../src/hooks/useKeyboardNavigation.ts | 279 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 packages/graph-viewer/src/hooks/useKeyboardNavigation.ts diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index fa224a5..5fce2f3 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -16,6 +16,7 @@ import { Hand2DOverlay } from './components/Hand2DOverlay' import { HandControlOverlay } from './components/HandControlOverlay' import { SettingsPanel } from './components/settings' import { useHandLockAndGrab } from './hooks/useHandLockAndGrab' +import { useKeyboardNavigation } from './hooks/useKeyboardNavigation' import type { GraphNode, FilterState, @@ -198,6 +199,24 @@ export default function App() { setForceConfig(DEFAULT_FORCE_CONFIG) }, []) + const handleToggleLabels = useCallback(() => { + setDisplayConfig(prev => ({ ...prev, showLabels: !prev.showLabels })) + }, []) + + // Keyboard navigation + const { shortcuts } = useKeyboardNavigation({ + nodes: (data?.nodes ?? []) as any, + selectedNode, + onNodeSelect: handleNodeSelect, + onReheat: handleReheat, + onToggleSettings: () => setSettingsPanelOpen(prev => !prev), + onToggleLabels: handleToggleLabels, + enabled: true, + }) + + // Log available shortcuts for debugging (remove in production) + void shortcuts + if (!isAuthenticated) { return } diff --git a/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts new file mode 100644 index 0000000..fc55c34 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts @@ -0,0 +1,279 @@ +import { useEffect, useCallback, useRef } from 'react' +import type { GraphNode, SimulationNode } from '../lib/types' + +interface UseKeyboardNavigationOptions { + nodes: SimulationNode[] + selectedNode: GraphNode | null + onNodeSelect: (node: GraphNode | null) => void + onReheat?: () => void + onResetView?: () => void + onToggleSettings?: () => void + onToggleLabels?: () => void + enabled?: boolean +} + +interface KeyboardShortcuts { + [key: string]: { + description: string + action: () => void + } +} + +/** + * Keyboard navigation for graph viewer + * Provides Obsidian-style keyboard shortcuts + */ +export function useKeyboardNavigation({ + nodes, + selectedNode, + onNodeSelect, + onReheat, + onResetView, + onToggleSettings, + onToggleLabels, + enabled = true, +}: UseKeyboardNavigationOptions) { + const nodesRef = useRef(nodes) + nodesRef.current = nodes + + const selectedRef = useRef(selectedNode) + selectedRef.current = selectedNode + + // Find nearest node in a direction from the selected node + const findNodeInDirection = useCallback( + (direction: 'up' | 'down' | 'left' | 'right' | 'forward' | 'backward') => { + const current = selectedRef.current + const allNodes = nodesRef.current + + if (!current || allNodes.length === 0) { + // If no selection, select first node + return allNodes[0] || null + } + + const currentNode = allNodes.find((n) => n.id === current.id) + if (!currentNode) return null + + const cx = currentNode.x ?? 0 + const cy = currentNode.y ?? 0 + const cz = currentNode.z ?? 0 + + // Filter candidates based on direction + const candidates = allNodes.filter((n) => { + if (n.id === current.id) return false + + const nx = n.x ?? 0 + const ny = n.y ?? 0 + const nz = n.z ?? 0 + + switch (direction) { + case 'up': + return ny > cy + case 'down': + return ny < cy + case 'left': + return nx < cx + case 'right': + return nx > cx + case 'forward': + return nz < cz + case 'backward': + return nz > cz + default: + return false + } + }) + + if (candidates.length === 0) return null + + // Find nearest candidate + let nearest = candidates[0] + let minDist = Infinity + + for (const n of candidates) { + const dx = (n.x ?? 0) - cx + const dy = (n.y ?? 0) - cy + const dz = (n.z ?? 0) - cz + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) + if (dist < minDist) { + minDist = dist + nearest = n + } + } + + return nearest + }, + [] + ) + + // Navigate to next/previous node in list order + const navigateSequential = useCallback((direction: 'next' | 'previous') => { + const allNodes = nodesRef.current + const current = selectedRef.current + + if (allNodes.length === 0) return null + + if (!current) { + return direction === 'next' ? allNodes[0] : allNodes[allNodes.length - 1] + } + + const currentIndex = allNodes.findIndex((n) => n.id === current.id) + if (currentIndex === -1) return allNodes[0] + + const nextIndex = + direction === 'next' + ? (currentIndex + 1) % allNodes.length + : (currentIndex - 1 + allNodes.length) % allNodes.length + + return allNodes[nextIndex] + }, []) + + // Keyboard event handler + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!enabled) return + + // Ignore if focus is in an input + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { + return + } + + const shortcuts: KeyboardShortcuts = { + // Navigation + ArrowUp: { + description: 'Navigate up', + action: () => { + const node = event.shiftKey + ? findNodeInDirection('backward') + : findNodeInDirection('up') + if (node) onNodeSelect(node) + }, + }, + ArrowDown: { + description: 'Navigate down', + action: () => { + const node = event.shiftKey + ? findNodeInDirection('forward') + : findNodeInDirection('down') + if (node) onNodeSelect(node) + }, + }, + ArrowLeft: { + description: 'Navigate left', + action: () => { + const node = findNodeInDirection('left') + if (node) onNodeSelect(node) + }, + }, + ArrowRight: { + description: 'Navigate right', + action: () => { + const node = findNodeInDirection('right') + if (node) onNodeSelect(node) + }, + }, + Tab: { + description: 'Next/previous node', + action: () => { + event.preventDefault() + const node = event.shiftKey + ? navigateSequential('previous') + : navigateSequential('next') + if (node) onNodeSelect(node) + }, + }, + + // Selection + Escape: { + description: 'Deselect', + action: () => { + onNodeSelect(null) + }, + }, + + // Actions + r: { + description: 'Reheat simulation', + action: () => { + if (!event.metaKey && !event.ctrlKey) { + onReheat?.() + } + }, + }, + R: { + description: 'Reset view', + action: () => { + if (event.shiftKey) { + onResetView?.() + } + }, + }, + ',': { + description: 'Toggle settings', + action: () => { + onToggleSettings?.() + }, + }, + l: { + description: 'Toggle labels', + action: () => { + if (!event.metaKey && !event.ctrlKey) { + onToggleLabels?.() + } + }, + }, + + // Help + '?': { + description: 'Show help', + action: () => { + // Could show a help modal in the future + console.log('Keyboard shortcuts:') + console.log(' Arrow keys: Navigate between nodes') + console.log(' Shift+Arrow Up/Down: Navigate in Z axis') + console.log(' Tab/Shift+Tab: Cycle through nodes') + console.log(' Escape: Deselect') + console.log(' R: Reheat simulation') + console.log(' Shift+R: Reset view') + console.log(' ,: Toggle settings') + console.log(' L: Toggle labels') + }, + }, + } + + const shortcut = shortcuts[event.key] + if (shortcut) { + shortcut.action() + } + }, + [enabled, findNodeInDirection, navigateSequential, onNodeSelect, onReheat, onResetView, onToggleSettings, onToggleLabels] + ) + + // Attach event listener + useEffect(() => { + if (!enabled) return + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [enabled, handleKeyDown]) + + // Return shortcuts info for help display + return { + shortcuts: [ + { key: '↑↓←→', description: 'Navigate between nodes' }, + { key: 'Shift+↑↓', description: 'Navigate in Z axis' }, + { key: 'Tab', description: 'Next node' }, + { key: 'Shift+Tab', description: 'Previous node' }, + { key: 'Esc', description: 'Deselect' }, + { key: 'R', description: 'Reheat simulation' }, + { key: 'Shift+R', description: 'Reset view' }, + { key: ',', description: 'Toggle settings' }, + { key: 'L', description: 'Toggle labels' }, + { key: '?', description: 'Show help' }, + ], + } +} From 137e61ce49e650650eb4b4ddfe176e2f748cb34c Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 24 Dec 2025 00:18:43 +0100 Subject: [PATCH 31/47] fix: Add ws dependency and update Vite scripts Added the 'ws' package to dependencies and updated npm scripts to use 'npx' for Vite commands in package.json for improved compatibility. --- packages/graph-viewer/package-lock.json | 24 +++++++++++++++++++++++- packages/graph-viewer/package.json | 9 +++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/graph-viewer/package-lock.json b/packages/graph-viewer/package-lock.json index 9db9a7e..f9ee0b6 100644 --- a/packages/graph-viewer/package-lock.json +++ b/packages/graph-viewer/package-lock.json @@ -28,7 +28,8 @@ "react-dom": "^18.3.1", "react-resizable-panels": "^2.1.7", "tailwind-merge": "^2.5.5", - "three": "^0.170.0" + "three": "^0.170.0", + "ws": "^8.18.3" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -5865,6 +5866,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/packages/graph-viewer/package.json b/packages/graph-viewer/package.json index 28a7b71..b8bcf5b 100644 --- a/packages/graph-viewer/package.json +++ b/packages/graph-viewer/package.json @@ -4,10 +4,10 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "npx vite", "dev:all": "node ./scripts/dev-all.mjs", - "build": "tsc -b && vite build", - "preview": "vite preview", + "build": "tsc -b && npx vite build", + "preview": "npx vite preview", "lint": "eslint ." }, "dependencies": { @@ -31,7 +31,8 @@ "react-dom": "^18.3.1", "react-resizable-panels": "^2.1.7", "tailwind-merge": "^2.5.5", - "three": "^0.170.0" + "three": "^0.170.0", + "ws": "^8.18.3" }, "devDependencies": { "@eslint/js": "^9.15.0", From df4099f27d2dfe17cb6f10fa8041bd27e836a685 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 24 Dec 2025 01:02:53 +0100 Subject: [PATCH 32/47] fix: Add tracking source toggle and improve hand gesture cleanup Introduces a UI toggle to switch between MediaPipe (webcam) and iPhone hand tracking sources, with initial source determined by URL parameters. Refactors tracking source state management, updates related props, and improves MediaPipe hand gesture cleanup to prevent errors during component unmount. --- packages/graph-viewer/src/App.tsx | 16 ++++++++-- .../src/components/GraphCanvas.tsx | 16 +++++----- .../src/components/HandControlOverlay.tsx | 30 ++++++++++++++++--- .../graph-viewer/src/hooks/useHandGestures.ts | 18 ++++++++++- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 5fce2f3..aab3a2f 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -104,6 +104,12 @@ export default function App() { const [performanceMode, setPerformanceMode] = useState(false) const [settingsPanelOpen, setSettingsPanelOpen] = useState(false) const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) + // Tracking source - check URL param on mount, then allow UI toggle + const [trackingSource, setTrackingSource] = useState<'mediapipe' | 'iphone'>(() => { + const params = new URLSearchParams(window.location.search) + return params.get('iphone') === 'true' ? 'iphone' : 'mediapipe' + }) + const [trackingInfo, setTrackingInfo] = useState<{ source: 'mediapipe' | 'iphone' iphoneUrl: string @@ -113,7 +119,7 @@ export default function App() { bridgeIps: string[] phonePort: number | null }>({ - source: 'mediapipe', + source: trackingSource, iphoneUrl: 'ws://localhost:8766/ws', iphoneConnected: false, hasLiDAR: false, @@ -122,6 +128,10 @@ export default function App() { phonePort: null, }) + const handleSourceChange = useCallback((source: 'mediapipe' | 'iphone') => { + setTrackingSource(source) + }, []) + // Filter state const [filters, setFilters] = useState({ types: [], @@ -361,6 +371,7 @@ export default function App() { onNodeSelect={handleNodeSelect} onNodeHover={handleNodeHover} gestureControlEnabled={gestureControlEnabled} + trackingSource={trackingSource} onGestureStateChange={handleGestureStateChange} onTrackingInfoChange={setTrackingInfo} performanceMode={performanceMode} @@ -390,7 +401,8 @@ export default function App() { ('mediapipe') +// Get iPhone WebSocket URL from URL params or default +function useIPhoneUrl() { const [iphoneUrl, setIphoneUrl] = useState('ws://localhost:8766/ws') useEffect(() => { const params = new URLSearchParams(window.location.search) - if (params.get('iphone') === 'true') { - setSource('iphone') - } const url = params.get('iphone_url') if (url) { setIphoneUrl(url) } }, []) - return { source, iphoneUrl, setSource } + return iphoneUrl } // Performance constants @@ -84,6 +80,7 @@ interface GraphCanvasProps { onNodeSelect: (node: GraphNode | null) => void onNodeHover: (node: GraphNode | null) => void gestureControlEnabled?: boolean + trackingSource?: 'mediapipe' | 'iphone' onGestureStateChange?: (state: GestureState) => void onTrackingInfoChange?: (info: { source: 'mediapipe' | 'iphone' @@ -112,6 +109,7 @@ export function GraphCanvas({ onNodeSelect, onNodeHover, gestureControlEnabled = false, + trackingSource: source = 'mediapipe', onGestureStateChange, onTrackingInfoChange, performanceMode = false, @@ -122,8 +120,8 @@ export function GraphCanvas({ typeColors = {}, onReheatReady, }: GraphCanvasProps) { - // Determine tracking source - const { source, iphoneUrl } = useTrackingSource() + // Get iPhone WebSocket URL (from URL param or default) + const iphoneUrl = useIPhoneUrl() // MediaPipe hand tracking (webcam) const { gestureState: mediapipeState, isEnabled: mediapipeActive } = useHandGestures({ diff --git a/packages/graph-viewer/src/components/HandControlOverlay.tsx b/packages/graph-viewer/src/components/HandControlOverlay.tsx index 49139b4..54569a4 100644 --- a/packages/graph-viewer/src/components/HandControlOverlay.tsx +++ b/packages/graph-viewer/src/components/HandControlOverlay.tsx @@ -4,6 +4,7 @@ interface HandControlOverlayProps { enabled: boolean lock: HandLockState source: 'mediapipe' | 'iphone' + onSourceChange?: (source: 'mediapipe' | 'iphone') => void iphoneConnected?: boolean hasLiDAR?: boolean iphoneUrl?: string @@ -16,6 +17,7 @@ export function HandControlOverlay({ enabled, lock, source, + onSourceChange, iphoneConnected = false, hasLiDAR = false, iphoneUrl, @@ -37,18 +39,38 @@ export function HandControlOverlay({ const m = lock.mode === 'idle' ? lock.metrics : lock.metrics return ( -
+
Hand Control {badge.text}
+ {/* Source Toggle */}
Source - - {source === 'iphone' ? 'iPhone Stream' : 'Webcam (MediaPipe)'} - +
+ + +
{source === 'iphone' && ( diff --git a/packages/graph-viewer/src/hooks/useHandGestures.ts b/packages/graph-viewer/src/hooks/useHandGestures.ts index 93dacc2..f9461bd 100644 --- a/packages/graph-viewer/src/hooks/useHandGestures.ts +++ b/packages/graph-viewer/src/hooks/useHandGestures.ts @@ -197,6 +197,7 @@ export function useHandGestures(options: UseHandGesturesOptions = {}) { const cameraRef = useRef(null) const prevStateRef = useRef(DEFAULT_STATE) const isInitializedRef = useRef(false) + const isCleaningUpRef = useRef(false) // Prevent send() after close() // Process MediaPipe results const onResults = useCallback((results: Results) => { @@ -309,6 +310,8 @@ export function useHandGestures(options: UseHandGesturesOptions = {}) { useEffect(() => { if (!enabled || isInitializedRef.current) return + isCleaningUpRef.current = false + const initializeHands = async () => { // Create video element for camera const video = document.createElement('video') @@ -335,8 +338,17 @@ export function useHandGestures(options: UseHandGesturesOptions = {}) { // Initialize camera const camera = new Camera(video, { onFrame: async () => { + // Guard against calling send() after close() + if (isCleaningUpRef.current) return if (handsRef.current && videoRef.current) { - await handsRef.current.send({ image: videoRef.current }) + try { + await handsRef.current.send({ image: videoRef.current }) + } catch (e) { + // Ignore errors during cleanup (BindingError from deleted WASM object) + if (!isCleaningUpRef.current) { + console.warn('MediaPipe send error:', e) + } + } } }, width: 640, @@ -351,10 +363,14 @@ export function useHandGestures(options: UseHandGesturesOptions = {}) { initializeHands().catch(console.error) return () => { + isCleaningUpRef.current = true cameraRef.current?.stop() + cameraRef.current = null handsRef.current?.close() + handsRef.current = null if (videoRef.current) { videoRef.current.remove() + videoRef.current = null } isInitializedRef.current = false } From faec9cf5a78028e2723d95977bc53f0067107077 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 24 Dec 2025 01:45:07 +0100 Subject: [PATCH 33/47] fix: Update iPhone hand tracking landmark mapping and debug logs Revised iPhone hand landmark keys to match Vision framework abbreviations and updated all related calculations to use new keys. Added debug logging for tracking source changes, incoming iPhone messages, and landmark keys. Improved LiDAR depth normalization for MediaPipe compatibility and enhanced error handling and message logging in the WebSocket connection. --- .../src/components/GraphCanvas.tsx | 27 ++-- .../src/components/Hand2DOverlay.tsx | 26 ++-- .../src/hooks/useIPhoneHandTracking.ts | 123 ++++++++++++------ 3 files changed, 116 insertions(+), 60 deletions(-) diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 8b9b226..8a0584c 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -147,6 +147,13 @@ export function GraphCanvas({ const gestureState = source === 'iphone' ? iphoneState : mediapipeState const gesturesActive = source === 'iphone' ? iphoneConnected : mediapipeActive + // Debug log when source changes + useEffect(() => { + console.log(`🎯 Tracking source: ${source}, enabled: ${gestureControlEnabled}, active: ${gesturesActive}`) + console.log(` iPhone: connected=${iphoneConnected}, hands=${iphoneState.handsDetected}`) + console.log(` MediaPipe: active=${mediapipeActive}, hands=${mediapipeState.handsDetected}`) + }, [source, gestureControlEnabled, gesturesActive, iphoneConnected, mediapipeActive, iphoneState.handsDetected, mediapipeState.handsDetected]) + useEffect(() => { onTrackingInfoChange?.({ source, @@ -568,9 +575,9 @@ function Scene({ } // Decay smoothed values - smoothed.translateZ *= 0.9 - smoothed.rotateX *= 0.9 - smoothed.rotateY *= 0.9 + smoothed.translateZ *= 0.9 + smoothed.rotateX *= 0.9 + smoothed.rotateY *= 0.9 // Gentle recenter: slowly pull cloud back toward origin group.position.x *= (1 - RECENTER_STRENGTH) @@ -652,14 +659,14 @@ function Scene({ {/* LOD Labels - only for selected/hovered/nearby nodes */} {displayConfig.showLabels && ( - + matchingIds={matchingIds} + /> )} {/* Expanded Node View - shows when a node is selected via hand */} diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 5ba7b20..1040835 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -583,19 +583,19 @@ function LaserBeam({ ray, stableRay, color, isGripped, hasHit = false }: LaserBe originX = (1 - ray.origin.x) * 100 originY = ray.origin.y * 100 - const centerX = 50 - const centerY = 50 - const handDeviationX = (originX - 50) * LASER_DEVIATION_SCALE - const handDeviationY = (originY - 50) * LASER_DEVIATION_SCALE - const targetX = centerX - handDeviationX * (1 - LASER_CENTER_BIAS) - const targetY = centerY - handDeviationY * (1 - LASER_CENTER_BIAS) - - const toCenterX = targetX - originX - const toCenterY = targetY - originY - const dist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) - const normX = dist > 0 ? toCenterX / dist : 0 - const normY = dist > 0 ? toCenterY / dist : 0 - const laserLength = dist + const centerX = 50 + const centerY = 50 + const handDeviationX = (originX - 50) * LASER_DEVIATION_SCALE + const handDeviationY = (originY - 50) * LASER_DEVIATION_SCALE + const targetX = centerX - handDeviationX * (1 - LASER_CENTER_BIAS) + const targetY = centerY - handDeviationY * (1 - LASER_CENTER_BIAS) + + const toCenterX = targetX - originX + const toCenterY = targetY - originY + const dist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) + const normX = dist > 0 ? toCenterX / dist : 0 + const normY = dist > 0 ? toCenterY / dist : 0 + const laserLength = dist endX = originX + normX * laserLength endY = originY + normY * laserLength diff --git a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts index e8953bc..8599022 100644 --- a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts +++ b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts @@ -12,28 +12,36 @@ import type { GestureState, HandLandmarks, PinchRay } from './useHandGestures' import type { NormalizedLandmarkList } from '@mediapipe/hands' // iPhone landmark names to MediaPipe indices +// Vision framework uses abbreviated keys: VNHLK + finger letter + joint +// W=wrist, T=thumb, I=index, M=middle, R=ring, P=pinky (little) const LANDMARK_MAP: Record = { - 'VNHLKJWRIST': 0, - 'VNHLKJTHUMBCMC': 1, - 'VNHLKJTHUMBMP': 2, - 'VNHLKJTHUMBIP': 3, - 'VNHLKJTHUMBTIP': 4, - 'VNHLKJINDEXMCP': 5, - 'VNHLKJINDEXPIP': 6, - 'VNHLKJINDEXDIP': 7, - 'VNHLKJINDEXTIP': 8, - 'VNHLKJMIDDLEMCP': 9, - 'VNHLKJMIDDLEPIP': 10, - 'VNHLKJMIDDLEDIP': 11, - 'VNHLKJMIDDLETIP': 12, - 'VNHLKJRINGMCP': 13, - 'VNHLKJRINGPIP': 14, - 'VNHLKJRINGDIP': 15, - 'VNHLKJRINGTIP': 16, - 'VNHLKJLITTLEMCP': 17, - 'VNHLKJLITTLEPIP': 18, - 'VNHLKJLITTLEDIP': 19, - 'VNHLKJLITTLETIP': 20, + // Wrist + 'VNHLKWRI': 0, + // Thumb (T) + 'VNHLKTCMC': 1, + 'VNHLKTMP': 2, + 'VNHLKTIP': 3, + 'VNHLKTTIP': 4, + // Index (I) + 'VNHLKIMCP': 5, + 'VNHLKIPIP': 6, + 'VNHLKIDIP': 7, + 'VNHLKITIP': 8, + // Middle (M) + 'VNHLKMMCP': 9, + 'VNHLKMPIP': 10, + 'VNHLKMDIP': 11, + 'VNHLKMTIP': 12, + // Ring (R) + 'VNHLKRMCP': 13, + 'VNHLKRPIP': 14, + 'VNHLKRDIP': 15, + 'VNHLKRTIP': 16, + // Little/Pinky (P) + 'VNHLKPMCP': 17, + 'VNHLKPPIP': 18, + 'VNHLKPDIP': 19, + 'VNHLKPTIP': 20, } interface IPhoneLandmark { @@ -86,8 +94,8 @@ function distance3D(a: IPhoneLandmark, b: IPhoneLandmark): number { // Calculate pinch strength from iPhone landmarks function calculatePinchStrength(landmarks: Record): number { - const thumbTip = landmarks['VNHLKJTHUMBTIP'] - const indexTip = landmarks['VNHLKJINDEXTIP'] + const thumbTip = landmarks['VNHLKTTIP'] + const indexTip = landmarks['VNHLKITIP'] if (!thumbTip || !indexTip) return 0 const dist = distance3D(thumbTip, indexTip) @@ -97,10 +105,10 @@ function calculatePinchStrength(landmarks: Record): numb // Closed fist strength from fingertip-to-wrist distances function calculateGrabStrength(landmarks: Record): number { - const wrist = landmarks['VNHLKJWRIST'] + const wrist = landmarks['VNHLKWRI'] if (!wrist) return 0 - const tips = ['VNHLKJTHUMBTIP', 'VNHLKJINDEXTIP', 'VNHLKJMIDDLETIP', 'VNHLKJRINGTIP', 'VNHLKJLITTLETIP'] + const tips = ['VNHLKTTIP', 'VNHLKITIP', 'VNHLKMTIP', 'VNHLKRTIP', 'VNHLKPTIP'] .map((k) => landmarks[k]) .filter(Boolean) as IPhoneLandmark[] if (tips.length < 3) return 0 @@ -110,29 +118,33 @@ function calculateGrabStrength(landmarks: Record): numbe return Math.max(0, Math.min(1, 1 - (avg - 0.08) / 0.17)) } -// Calculate pinch ray from iPhone landmarks (with REAL depth!) +// Calculate pinch ray from iPhone landmarks (with normalized depth) function calculatePinchRay(landmarks: Record, hasLiDAR: boolean): PinchRay { - const thumbTip = landmarks['VNHLKJTHUMBTIP'] - const indexTip = landmarks['VNHLKJINDEXTIP'] - const wrist = landmarks['VNHLKJWRIST'] + const thumbTip = landmarks['VNHLKTTIP'] + const indexTip = landmarks['VNHLKITIP'] + const wrist = landmarks['VNHLKWRI'] if (!thumbTip || !indexTip || !wrist) { return { origin: { x: 0.5, y: 0.5, z: 0 }, direction: { x: 0, y: 0, z: -1 }, isValid: false, strength: 0 } } + // Normalize depths for consistent scaling + const thumbZ = normalizeLiDARDepth(thumbTip.z, hasLiDAR) + const indexZ = normalizeLiDARDepth(indexTip.z, hasLiDAR) + const wristZ = normalizeLiDARDepth(wrist.z, hasLiDAR) + // Origin: midpoint between thumb and index tips const origin = { x: (thumbTip.x + indexTip.x) / 2, y: (thumbTip.y + indexTip.y) / 2, - // Use REAL depth from LiDAR if available! - z: hasLiDAR ? (thumbTip.z + indexTip.z) / 2 : 0, + z: (thumbZ + indexZ) / 2, } // Direction: from wrist through pinch point const rawDir = { x: origin.x - wrist.x, y: origin.y - wrist.y, - z: hasLiDAR ? (origin.z - wrist.z) : -0.5, // Default forward if no LiDAR + z: origin.z - wristZ || -0.1, // Small default forward if no difference } const length = Math.sqrt(rawDir.x * rawDir.x + rawDir.y * rawDir.y + rawDir.z * rawDir.z) @@ -146,8 +158,20 @@ function calculatePinchRay(landmarks: Record, hasLiDAR: return { origin, direction, isValid, strength } } +// Normalize LiDAR depth (meters) to MediaPipe-like relative depth +// LiDAR: 0.3m (close) to 3.0m (far) -> MediaPipe-like: 0.1 to -0.3 +// Reference: ~1.0m is "neutral" -> 0 +function normalizeLiDARDepth(depthMeters: number, hasLiDAR: boolean): number { + if (!hasLiDAR || depthMeters === 0) return 0 + + // Invert and scale: closer = positive, farther = negative + // At 1.0m -> 0, at 0.5m -> 0.1, at 2.0m -> -0.2 + const normalized = (1.0 - depthMeters) * 0.2 + return Math.max(-0.5, Math.min(0.5, normalized)) +} + // Convert iPhone landmarks to MediaPipe-compatible format -function convertToMediaPipeLandmarks(landmarks: Record): NormalizedLandmarkList { +function convertToMediaPipeLandmarks(landmarks: Record, hasLiDAR: boolean = false): NormalizedLandmarkList { const result: NormalizedLandmarkList = [] // Initialize all 21 landmarks with defaults @@ -162,7 +186,8 @@ function convertToMediaPipeLandmarks(landmarks: Record): result[idx] = { x: lm.x, y: lm.y, - z: lm.z, + // Normalize LiDAR depth to MediaPipe-like values + z: normalizeLiDARDepth(lm.z, hasLiDAR), visibility: 1, } } @@ -213,6 +238,8 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} const frameCountRef = useRef(0) const lastFpsTimeRef = useRef(Date.now()) const reconnectTimeoutRef = useRef() + const messageCountRef = useRef(0) + const hasLoggedLandmarksRef = useRef(false) // Process incoming hand data const processMessage = useCallback((data: IPhoneMessage) => { @@ -235,7 +262,16 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} // Process each hand for (const hand of data.hands) { - const landmarks = convertToMediaPipeLandmarks(hand.landmarks) + // Debug: log the actual landmark keys from iPhone (once per connection) + if (!hasLoggedLandmarksRef.current && Object.keys(hand.landmarks).length > 0) { + hasLoggedLandmarksRef.current = true + console.log('📍 iPhone landmark keys:', Object.keys(hand.landmarks)) + console.log('📍 Expected keys:', Object.keys(LANDMARK_MAP)) + const sampleEntry = Object.entries(hand.landmarks)[0] + console.log('📍 Sample landmark:', sampleEntry?.[0], '→', sampleEntry?.[1]) + } + + const landmarks = convertToMediaPipeLandmarks(hand.landmarks, hand.hasLiDARDepth) const handData: HandLandmarks = { landmarks, worldLandmarks: landmarks, @@ -322,22 +358,35 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} wsRef.current = ws ws.onopen = () => { - console.log('📱 Connected to iPhone hand tracking') + console.log('📱 Connected to iPhone hand tracking bridge') setIsConnected(true) + messageCountRef.current = 0 + hasLoggedLandmarksRef.current = false } ws.onmessage = (event) => { try { const data = JSON.parse(event.data) as IPhoneMessage + messageCountRef.current++ + + // Log first few messages and then periodically + if (messageCountRef.current <= 3 || messageCountRef.current % 100 === 0) { + console.log(`📨 Message #${messageCountRef.current}:`, data.type, data.hands?.length || 0, 'hands') + } + if (data.type === 'hand_tracking') { processMessage(data) } else if (data.type === 'bridge_status') { + console.log('📡 Bridge status:', data) if (typeof data.phoneConnected === 'boolean') setPhoneConnected(data.phoneConnected) if (Array.isArray(data.ips)) setBridgeIps(data.ips) if (typeof data.phonePort === 'number') setPhonePort(data.phonePort) + } else { + // Debug: log unexpected message types + console.log('📨 Unknown message type:', data.type, data) } } catch (e) { - console.error('Parse error:', e) + console.error('Parse error:', e, event.data) } } From 445b4bb15a3c10f4dd88d18d6ff5de2f3b84e853 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 24 Dec 2025 03:50:04 +0100 Subject: [PATCH 34/47] fix: Improve hand gesture controls and add reset view Refactors hand gesture controls to use displacement-based panning and depth for more intuitive graph manipulation, replacing velocity-based movement. Adds a 'Reset View' button to center the graph and reset rotation, with support for this callback throughout the app. Enhances hand overlay visuals with activation flash, improved opacity logic, and more accurate laser/pointing detection. Updates hand lock and grab logic for more robust pose detection and longer lock persistence. --- packages/graph-viewer/src/App.tsx | 5 + .../src/components/GraphCanvas.tsx | 130 +++++++++++------- .../src/components/Hand2DOverlay.tsx | 115 ++++++++++++---- .../src/components/HandControlOverlay.tsx | 15 +- .../src/hooks/useHandLockAndGrab.ts | 106 ++++++++------ 5 files changed, 250 insertions(+), 121 deletions(-) diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index aab3a2f..0468888 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -156,6 +156,9 @@ export default function App() { // Reheat callback - will be set by GraphCanvas const [reheatFn, setReheatFn] = useState<(() => void) | null>(null) + // Reset view callback - will be set by GraphCanvas + const [resetViewFn, setResetViewFn] = useState<(() => void) | null>(null) + const handleGestureStateChange = useCallback((state: GestureState) => { setGestureState(state) }, []) @@ -381,6 +384,7 @@ export default function App() { relationshipVisibility={relationshipVisibility} typeColors={data?.meta?.type_colors} onReheatReady={setReheatFn} + onResetViewReady={setResetViewFn} /> {/* 2D Hand Overlay (on top of canvas, life-size) */} @@ -403,6 +407,7 @@ export default function App() { lock={handLock} source={trackingSource} onSourceChange={handleSourceChange} + onResetView={resetViewFn ?? undefined} iphoneConnected={trackingInfo.iphoneConnected} hasLiDAR={trackingInfo.hasLiDAR} iphoneUrl={trackingInfo.iphoneUrl} diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 8a0584c..21565aa 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -65,11 +65,9 @@ const SPHERE_SEGMENTS = 12 // Reduced from 32 - good enough for small spheres const LABEL_DISTANCE_THRESHOLD = 80 // Only show labels for nodes within this distance const MAX_VISIBLE_LABELS = 10 // Maximum labels to show at once (for LOD) -// Gesture smoothing constants - prevent sudden movements -const GESTURE_SMOOTHING = 0.15 // Lower = smoother but laggier (0.1-0.3 recommended) +// Gesture control constants const GESTURE_DEADZONE = 0.005 // Ignore tiny movements const MAX_TRANSLATE_SPEED = 3 // Cap cloud translation per frame -const RECENTER_STRENGTH = 0.01 // How strongly to pull cloud back to center interface GraphCanvasProps { nodes: GraphNode[] @@ -98,6 +96,7 @@ interface GraphCanvasProps { relationshipVisibility?: RelationshipVisibility typeColors?: Record onReheatReady?: (reheat: () => void) => void + onResetViewReady?: (resetView: () => void) => void } export function GraphCanvas({ @@ -119,6 +118,7 @@ export function GraphCanvas({ relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, typeColors = {}, onReheatReady, + onResetViewReady, }: GraphCanvasProps) { // Get iPhone WebSocket URL (from URL param or default) const iphoneUrl = useIPhoneUrl() @@ -190,6 +190,7 @@ export function GraphCanvas({ relationshipVisibility={relationshipVisibility} typeColors={typeColors} onReheatReady={onReheatReady} + onResetViewReady={onResetViewReady} /> ) @@ -199,6 +200,7 @@ interface SceneProps extends Omit void) => void } function Scene({ @@ -218,6 +220,7 @@ function Scene({ relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, typeColors = {}, onReheatReady, + onResetViewReady, }: SceneProps) { const { nodes: layoutNodes, isSimulating, reheat } = useForceLayout({ nodes, edges, forceConfig }) @@ -235,6 +238,23 @@ function Scene({ onReheatReady(reheat) } }, [onReheatReady, reheat]) + + // Reset view function - centers the graph and resets rotation + const resetView = useCallback(() => { + if (groupRef.current) { + groupRef.current.position.set(0, 0, 0) + groupRef.current.rotation.set(0, 0, 0) + } + if (controlsRef.current) { + controlsRef.current.reset() + } + }, []) + + useEffect(() => { + if (onResetViewReady) { + onResetViewReady(resetView) + } + }, [onResetViewReady, resetView]) const [autoRotate, setAutoRotate] = useState(false) const groupRef = useRef(null) const controlsRef = useRef(null) @@ -392,12 +412,31 @@ function Scene({ ) } - setAimHit(hit) + // Snap-to-node: if we have a hit, bias the aim point to the node CENTER (feels "magnetic") + // This also makes click-open deterministic. + const snapNode = hit ? (nodeById.get(hit.nodeId) ?? null) : null + const snappedHit: NodeHit | null = + hit && snapNode + ? { + ...hit, + point: { + x: snapNode.x ?? hit.point.x, + y: snapNode.y ?? hit.point.y, + z: snapNode.z ?? hit.point.z, + }, + } + : hit - // World-space aim point for rendering (hit point if present; otherwise plane intersection near graph center) + setAimHit(snappedHit) + + // World-space aim point for rendering (snapped node center if present; otherwise plane intersection near graph center) let aimPointWorld: THREE.Vector3 | null = null - if (group && hit) { - aimPointWorld = new THREE.Vector3(hit.point.x, hit.point.y, hit.point.z).applyMatrix4(group.matrixWorld) + if (group && snappedHit) { + aimPointWorld = new THREE.Vector3( + snappedHit.point.x, + snappedHit.point.y, + snappedHit.point.z + ).applyMatrix4(group.matrixWorld) } else if (group) { const center = group.getWorldPosition(new THREE.Vector3()) const normal = camera.getWorldDirection(new THREE.Vector3()).normalize() @@ -411,14 +450,14 @@ function Scene({ setAimWorldPoint({ x: aimPointWorld.x, y: aimPointWorld.y, z: aimPointWorld.z }) // Hover highlight - const hoverNode = hit ? (nodeById.get(hit.nodeId) ?? null) : null + const hoverNode = snappedHit ? (nodeById.get(snappedHit.nodeId) ?? null) : null onNodeHover(hoverNode as GraphNode | null) // Pinch click (only when aiming). Trigger on release to allow cancel. const pinchActive = m.pinch > 0.75 if (pinchActive && !pinchDownRef.current) { pinchDownRef.current = true - pressedNodeRef.current = hit?.nodeId ?? null + pressedNodeRef.current = snappedHit?.nodeId ?? null return } @@ -426,8 +465,8 @@ function Scene({ pinchDownRef.current = false const pressed = pressedNodeRef.current pressedNodeRef.current = null - if (pressed && hit?.nodeId === pressed) { - selectNodeById(pressed, hit.point) + if (pressed && snappedHit?.nodeId === pressed) { + selectNodeById(pressed, snappedHit.point) } } }, [ @@ -518,24 +557,22 @@ function Scene({ onNodeSelect(null) }, [onNodeSelect]) - // Smoothed gesture values (to prevent sudden movements) - const smoothedGestureRef = useRef({ - translateZ: 0, - rotateX: 0, - rotateY: 0, - }) + // Track world position at grab start for displacement-based movement + const grabStartPosRef = useRef({ x: 0, y: 0, z: 0 }) + + // Smoothed pan for non-grab controls only + const smoothedPanZRef = useRef(0) // Clamp helper const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) - // Apply gesture controls to move the CLOUD (not camera) with smoothing - // Now also uses the new interaction state for more precise control + // Apply gesture controls to move the CLOUD (not camera) + // Grab = physically grab the world and drag it around (displacement-based, not velocity) useEffect(() => { if (!gestureControlEnabled || !groupRef.current) return if (!gestureState.isTracking) return const group = groupRef.current - const smoothed = smoothedGestureRef.current // Use the new interaction state for cloud manipulation const { rotationDelta, zoomDelta, dragDeltaZ, isDragging } = interactionState @@ -543,18 +580,23 @@ function Scene({ const usingGrabControls = handLock.mode === 'locked' && handLock.grabbed if (usingGrabControls) { - // Exponential zoom velocity already computed; smooth + clamp - smoothed.translateZ += (grabDeltas.zoom - smoothed.translateZ) * GESTURE_SMOOTHING - const zVel = clamp(smoothed.translateZ, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) - if (Math.abs(zVel) > GESTURE_DEADZONE) { - group.position.z += zVel + // On first frame of grab, capture current world position + if (grabDeltas.grabStart) { + grabStartPosRef.current = { + x: group.position.x, + y: group.position.y, + z: group.position.z, + } + return // Don't apply deltas on first frame } - // Rotation (pitch/yaw) - const rx = clamp(grabDeltas.rotateX, -0.08, 0.08) - const ry = clamp(grabDeltas.rotateY, -0.08, 0.08) - if (Math.abs(rx) > GESTURE_DEADZONE) group.rotation.x += rx - if (Math.abs(ry) > GESTURE_DEADZONE) group.rotation.y += ry + // DISPLACEMENT-BASED: Set position relative to grab start, not add velocity + // panX/panY/panZ are how much to offset from the grab start position + const startPos = grabStartPosRef.current + + group.position.x = startPos.x + grabDeltas.panX + group.position.y = startPos.y + grabDeltas.panY + group.position.z = startPos.z + grabDeltas.panZ } else { // Apply zoom (two-hand spread/pinch) if (Math.abs(zoomDelta) > GESTURE_DEADZONE) { @@ -568,23 +610,13 @@ function Scene({ // Apply Z drag (single hand push/pull when not selecting a node) if (!isDragging && Math.abs(dragDeltaZ) > GESTURE_DEADZONE) { - smoothed.translateZ += (dragDeltaZ - smoothed.translateZ) * GESTURE_SMOOTHING - const clamped = clamp(smoothed.translateZ, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) + smoothedPanZRef.current += (dragDeltaZ - smoothedPanZRef.current) * 0.2 + const clamped = clamp(smoothedPanZRef.current, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) group.position.z += clamped + } else { + smoothedPanZRef.current *= 0.9 } } - - // Decay smoothed values - smoothed.translateZ *= 0.9 - smoothed.rotateX *= 0.9 - smoothed.rotateY *= 0.9 - - // Gentle recenter: slowly pull cloud back toward origin - group.position.x *= (1 - RECENTER_STRENGTH) - group.position.y *= (1 - RECENTER_STRENGTH) - group.position.z *= (1 - RECENTER_STRENGTH) - group.rotation.x *= (1 - RECENTER_STRENGTH) - group.rotation.y *= (1 - RECENTER_STRENGTH) }, [gestureControlEnabled, gestureState, interactionState, handLock, grabDeltas]) return ( @@ -699,8 +731,8 @@ function Scene({ /> - {/* Laser beam only while pinching */} - {handLock.metrics.pinch > 0.75 && aimRayRef.current && ( + {/* Laser beam while AIMING (brighter while pinching) */} + {aimRayRef.current && ( - + 0.75 ? '#ffffff' : aimHit ? '#fbbf24' : '#60a5fa'} + transparent + opacity={handLock.metrics.pinch > 0.75 ? 0.9 : 0.35} + /> )} diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 1040835..870dfaa 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -65,6 +65,24 @@ export function Hand2DOverlay({ const [rightSmoothed, setRightSmoothed] = useState(null) const animationRef = useRef() + // Track activation flash + const [activationFlash, setActivationFlash] = useState(false) + const prevLockModeRef = useRef('idle') + + // Flash when transitioning to locked + useEffect(() => { + const prevMode = prevLockModeRef.current + const currentMode = handLock?.mode ?? 'idle' + + if (prevMode !== 'locked' && currentMode === 'locked') { + // Just became locked - trigger flash + setActivationFlash(true) + setTimeout(() => setActivationFlash(false), 400) + } + + prevLockModeRef.current = currentMode + }, [handLock?.mode]) + // Smoothing and ghost effect useEffect(() => { if (!enabled) return @@ -156,26 +174,49 @@ export function Hand2DOverlay({ if (!enabled || !gestureState.isTracking) return null - // Visual state: ghosty until locked; solid when actively grabbing/pinching + // Visual state: VERY faint until locked, then solid + // Hands should be almost invisible until activation gesture is performed const lockMode = handLock?.mode const isLocked = lockMode === 'locked' - const isActive = - isLocked && (((handLock as any).grabbed as boolean) || (((handLock as any).metrics?.pinch as number) ?? 0) > 0.75) + const isGrabbing = isLocked && ((handLock as any).grabbed as boolean) + const isPinching = isLocked && (((handLock as any).metrics?.pinch as number) ?? 0) > 0.75 + const isActive = isGrabbing || isPinching + + // Opacity: very faint when not locked, full when locked and active const opacityMultiplier = !handLock - ? 1 - : lockMode === 'candidate' - ? 0.55 - : isLocked - ? (isActive ? 1.1 : 0.8) - : 0.6 + ? 0.15 // No lock state provided - very faint + : lockMode === 'idle' + ? 0.12 // Idle - barely visible ghost + : lockMode === 'candidate' + ? 0.35 // Acquiring - starting to show + : isLocked + ? (isActive ? 1.2 : 0.85) // Locked - full visibility; brighter when active + : 0.12 // Fallback - very faint + + // Check if pointing (for laser visibility) + const isPointing = lockMode === 'locked' && ((handLock as any).metrics?.point as number ?? 0) > 0.5 // Check if both hands are gripping (for two-hand manipulation) const leftGripping = gestureState.leftPinchRay?.isValid const rightGripping = gestureState.rightPinchRay?.isValid const bothGripping = leftGripping && rightGripping + // Laser visibility: show while aiming (pointing) or while pinching + const leftLaserActive = isPointing || (gestureState.leftPinchRay?.strength ?? 0) > 0.3 + const rightLaserActive = isPointing || (gestureState.rightPinchRay?.strength ?? 0) > 0.3 + return (
+ {/* Activation flash overlay */} + {activationFlash && ( +
+ )} )} - {/* Left laser - require stable ray to avoid misleading "center-biased" fallback */} - {showLaser && leftStableRay?.screenHit && gestureState.leftPinchRay && gestureState.leftPinchRay.strength > 0.3 && ( + {/* Left laser */} + {showLaser && leftLaserActive && leftStableRay?.screenHit && gestureState.leftPinchRay && ( )} - {/* Right laser - use stable ray if available */} - {showLaser && rightStableRay?.screenHit && gestureState.rightPinchRay && gestureState.rightPinchRay.strength > 0.3 && ( + {/* Right laser */} + {showLaser && rightLaserActive && rightStableRay?.screenHit && gestureState.rightPinchRay && ( 0.5 - const clamp01 = (v: number) => Math.max(0, Math.min(1, v)) - const t = isMeters ? clamp01((wristZ - 0.25) / 1.75) : clamp01((wristZ + 0.2) / 0.4) - const clampedScale = 0.6 + t * 1.1 // 0.6 .. 1.7 + const isMeters = Math.abs(wristZ) > 0.5 // Raw LiDAR vs Normalized + + // Calculate scale factor based on depth - INVERTED MAPPING + // Physical hand closer to camera -> Virtual hand moves AWAY (smaller) + // Physical hand moves back -> Virtual hand moves CLOSER (larger) + + let scaleFactor = 1.0 + if (isMeters) { + // LiDAR (meters): 0.3m (close) .. 1.5m (far) + // Close (0.3m) -> Scale 0.6 (distant/small) + // Far (1.0m) -> Scale 1.2 (close/large) + const t = Math.max(0, Math.min(1, (wristZ - 0.3) / 0.7)) // 0 at 0.3m, 1 at 1.0m + scaleFactor = 0.6 + t * 0.8 + } else { + // MediaPipe (normalized relative to wrist): + // +0.1 (close to camera) -> Scale 0.6 + // -0.2 (far from camera) -> Scale 1.2 + const t = Math.max(0, Math.min(1, (0.1 - wristZ) / 0.3)) // 0 at +0.1, 1 at -0.2 + scaleFactor = 0.6 + t * 0.8 + } - // Un-mirror the X coordinate + const clampedScale = Math.max(0.5, Math.min(1.8, scaleFactor)) + + // Un-mirror the X coordinate (selfie-style) and convert to SVG space const toSvg = (lm: { x: number; y: number }) => ({ x: (1 - lm.x) * 100, y: lm.y * 100, @@ -336,7 +398,7 @@ function GhostHand({ const handId = Math.round(points[0].x * 10) // Helper: get perpendicular offset for finger width - const getPerpendicular = (p1: {x: number, y: number}, p2: {x: number, y: number}, width: number) => { + const getPerpendicular = (p1: Point2, p2: Point2, width: number) => { const dx = p2.x - p1.x const dy = p2.y - p1.y const len = Math.sqrt(dx * dx + dy * dy) || 1 @@ -367,7 +429,7 @@ function GhostHand({ const tipLeft = `${lastPt.x + tipPerp.x},${lastPt.y + tipPerp.y}` const tipRight = `${lastPt.x - tipPerp.x},${lastPt.y - tipPerp.y}` - return `M ${leftSide[0]} L ${leftSide.join(' L ')} L ${tipLeft} A ${width} ${width} 0 0 1 ${tipRight} L ${rightSide.join(' L ')} Z` + return `M ${leftSide[0]} L ${leftSide.join(' L ')} L ${tipLeft} A ${width} ${width} 0 0 1 ${tipRight} L ${rightSide.join(' L ')} Z`; } // Create palm shape connecting all finger bases @@ -397,7 +459,7 @@ function GhostHand({ Q ${wrist.x + palmWidth * 1.5} ${(wrist.y + pinkyBase.y) / 2} ${wrist.x} ${wrist.y + palmWidth} Z - ` + `; } // Finger definitions: [landmark indices] @@ -453,10 +515,7 @@ function GhostHand({ {/* Shadow layer */} {/* Palm base shape */} - + {/* Fingers - rendered back to front for proper overlapping */} {[...fingers].reverse().map((finger, idx) => ( diff --git a/packages/graph-viewer/src/components/HandControlOverlay.tsx b/packages/graph-viewer/src/components/HandControlOverlay.tsx index 54569a4..445efad 100644 --- a/packages/graph-viewer/src/components/HandControlOverlay.tsx +++ b/packages/graph-viewer/src/components/HandControlOverlay.tsx @@ -5,6 +5,7 @@ interface HandControlOverlayProps { lock: HandLockState source: 'mediapipe' | 'iphone' onSourceChange?: (source: 'mediapipe' | 'iphone') => void + onResetView?: () => void iphoneConnected?: boolean hasLiDAR?: boolean iphoneUrl?: string @@ -18,6 +19,7 @@ export function HandControlOverlay({ lock, source, onSourceChange, + onResetView, iphoneConnected = false, hasLiDAR = false, iphoneUrl, @@ -43,7 +45,18 @@ export function HandControlOverlay({
Hand Control - {badge.text} +
+ {onResetView && ( + + )} + {badge.text} +
{/* Source Toggle */} diff --git a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts index b064ef8..31947b0 100644 --- a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts +++ b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts @@ -55,16 +55,19 @@ export type HandLockState = export interface CloudControlDeltas { /** zoom velocity (positive -> zoom in, negative -> zoom out) */ zoom: number - /** rotation deltas (radians) */ - rotateX: number - rotateY: number + /** Displacement-based pan: how much to offset from grab start position */ + panX: number + panY: number + panZ: number + /** Is this the first frame of a grab? (used to capture initial world position) */ + grabStart: boolean } const DEFAULT_CONFIDENCE = 0.7 // Tunables (these matter a lot for UX) const ACQUIRE_FRAMES_REQUIRED = 4 -const LOCK_PERSIST_MS = 450 +const LOCK_PERSIST_MS = 2000 // 2 seconds before unlocking when hand leaves frame const SPREAD_THRESHOLD = 0.65 const PALM_FACING_THRESHOLD = 0.55 @@ -73,10 +76,7 @@ const GRAB_ON_THRESHOLD = 0.72 const GRAB_OFF_THRESHOLD = 0.45 // Control sensitivity -const ROTATE_GAIN = 1.8 -const DEPTH_GAIN = 2.6 // exponential factor const DEPTH_DEADZONE = 0.01 -const ROT_DEADZONE = 0.003 function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)) @@ -146,29 +146,39 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | // Pointing pose score: // index extended while the other 3 fingers are relatively curled. + // NOTE: We don't require palm facing camera - natural pointing works from any angle const idxExt = fingerExtensionScore(wrist, indexMcp, indexTip) const midExt = fingerExtensionScore(wrist, middleMcp, middleTip) const ringExt = fingerExtensionScore(wrist, ringMcp, ringTip) const pinkyExt = fingerExtensionScore(wrist, pinkyMcp, pinkyTip) const others = clamp((midExt + ringExt + pinkyExt) / 3, 0, 1) - const palmForward = clamp((palmFacing + 1) / 2, 0, 1) - const point = clamp(idxExt * (1 - others) * palmForward, 0, 1) + // Point score: index extended (idxExt high) AND other fingers curled (others low) + // If index is extended more than others, it's pointing + const pointRaw = clamp((idxExt - others) * 2, 0, 1) + const point = idxExt > 0.5 && others < 0.5 ? pointRaw : 0 - // Grab: use state.grabStrength only if it's meaningful (computed by source); - // otherwise approximate from fingertip distances to wrist (closed fist => smaller) + // Grab: closed fist = ALL fingers curled including index + // Exclude index from grab calculation to distinguish from pointing const hasGrabStrength = typeof state.grabStrength === 'number' && state.handsDetected >= 1 && state.grabStrength > 0 // avoid default 0 from sources that don't compute it let grab = hasGrabStrength ? clamp(state.grabStrength, 0, 1) : 0 if (!hasGrabStrength) { + // For grab, require ALL fingers to be curled (including index) const dw1 = length2(indexTip.x - wrist.x, indexTip.y - wrist.y) const dw2 = length2(middleTip.x - wrist.x, middleTip.y - wrist.y) const dw3 = length2(ringTip.x - wrist.x, ringTip.y - wrist.y) const dw4 = length2(pinkyTip.x - wrist.x, pinkyTip.y - wrist.y) const avgDw = (dw1 + dw2 + dw3 + dw4) / 4 - // Typical: ~0.10 fist .. ~0.25 open - grab = clamp(1 - safeDiv(avgDw - 0.10, 0.15), 0, 1) + // All fingers must be close to wrist + const allCurled = dw1 < 0.15 && dw2 < 0.15 && dw3 < 0.15 && dw4 < 0.15 + grab = allCurled ? clamp(1 - safeDiv(avgDw - 0.08, 0.07), 0, 1) : 0 + } + + // Mutual exclusion: if pointing, suppress grab + if (point > 0.5) { + grab = 0 } // Depth: prefer pinch ray origin z when present (iPhone LiDAR mapped into landmarks z) @@ -186,12 +196,6 @@ function isAcquirePose(m: HandLockMetrics) { return m.spread >= SPREAD_THRESHOLD && m.palmFacing >= PALM_FACING_THRESHOLD && m.confidence >= 0.4 } -function expResponse(delta: number, gain: number) { - const s = Math.sign(delta) - const a = Math.abs(delta) - return s * (Math.exp(a * gain) - 1) -} - export function useHandLockAndGrab(state: GestureState, enabled: boolean) { const lockRef = useRef({ mode: 'idle', metrics: null }) @@ -207,7 +211,7 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { const next = useMemo((): { lock: HandLockState; deltas: CloudControlDeltas } => { if (!enabled) { lockRef.current = { mode: 'idle', metrics: null } - return { lock: lockRef.current, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } const prev = lockRef.current @@ -219,11 +223,11 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { if (nowMs - prev.lastSeenMs <= LOCK_PERSIST_MS) { const persisted: HandLockState = { ...prev, metrics: prev.metrics } lockRef.current = persisted - return { lock: persisted, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: persisted, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } } lockRef.current = { mode: 'idle', metrics: null } - return { lock: lockRef.current, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } // Hand seen: update FSM @@ -231,11 +235,11 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { if (isAcquirePose(metrics)) { const candidate: HandLockState = { mode: 'candidate', metrics, frames: 1 } lockRef.current = candidate - return { lock: candidate, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } const idle: HandLockState = { mode: 'idle', metrics } lockRef.current = idle - return { lock: idle, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } if (prev.mode === 'candidate') { @@ -255,16 +259,16 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { lastSeenMs: nowMs, } lockRef.current = locked - return { lock: locked, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: locked, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } const candidate: HandLockState = { mode: 'candidate', metrics, frames } lockRef.current = candidate - return { lock: candidate, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } // lost candidate const idle: HandLockState = { mode: 'idle', metrics } lockRef.current = idle - return { lock: idle, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } // locked @@ -285,30 +289,42 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { lastSeenMs: nowMs, } - let deltas: CloudControlDeltas = { zoom: 0, rotateX: 0, rotateY: 0 } + let deltas: CloudControlDeltas = { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } if (grabbed) { - const anchor = prev.grabAnchor ?? { x, y, depth: metrics.depth } - // On first grab frame, set anchor - if (!prev.grabbed) { - lock.grabAnchor = anchor + const isFirstGrabFrame = !prev.grabbed + + if (isFirstGrabFrame) { + // First frame of grab - set anchor and signal to capture world position + lock.grabAnchor = { x, y, depth: metrics.depth } + deltas.grabStart = true lockRef.current = lock return { lock, deltas } } - // Depth -> zoom (exponential) - // User mental model: pull fist toward body (farther from camera) zooms IN; push toward screen/camera zooms OUT. - const dz = metrics.depth - anchor.depth - if (Math.abs(dz) > DEPTH_DEADZONE) { - deltas.zoom = expResponse(dz, DEPTH_GAIN) - } + const anchor = prev.grabAnchor ?? { x, y, depth: metrics.depth } + + // Calculate displacement from anchor (how far hand moved since grab started) + const dx = x - anchor.x // hand moved right in screen space (0-1 normalized) + const dy = y - anchor.y // hand moved down in screen space + const dz = metrics.depth - anchor.depth // hand moved toward/away from camera + + // PAN the world: displacement-based, not velocity + // Scale: moving hand across half the screen (~0.5) should move graph ~150 world units + // That's a reasonable "arm's reach" mapping + const PAN_GAIN = 300 // world units per full screen unit of hand movement - // Position -> rotation - const dx = x - anchor.x - const dy = y - anchor.y - if (Math.abs(dx) > ROT_DEADZONE || Math.abs(dy) > ROT_DEADZONE) { - deltas.rotateY = dx * ROTATE_GAIN - deltas.rotateX = -dy * ROTATE_GAIN + deltas.panX = -dx * PAN_GAIN // drag right = world moves right (opposite sign because we're moving world) + deltas.panY = dy * PAN_GAIN // drag down = world moves down (Y is flipped in screen coords) + + // Depth -> Z translation + // Moving hand ~0.2 depth units should translate maybe 50-100 world units + const DEPTH_PAN_GAIN = 250 + deltas.panZ = dz * DEPTH_PAN_GAIN + + // Also apply zoom based on depth (optional, can remove if too much) + if (Math.abs(dz) > DEPTH_DEADZONE) { + deltas.zoom = dz * 0.5 // gentle zoom } } else { lock.grabAnchor = undefined @@ -319,7 +335,7 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { } lockRef.current = { mode: 'idle', metrics } - return { lock: lockRef.current, deltas: { zoom: 0, rotateX: 0, rotateY: 0 } } + return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } }, [ enabled, chosenHand, From 5400d2f3193391483c91722d03ffeb5996a93fd6 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 24 Dec 2025 12:00:36 +0100 Subject: [PATCH 35/47] feat: Add advanced UI features: bookmarks, lasso, pathfinding, tag cloud Introduces several new interactive features to the graph viewer, including a bookmarks panel for saving and restoring camera positions, lasso selection for bulk node actions, animated edge particles, a pathfinding overlay, a timeline bar for time travel, a radial menu for node actions, and a tag cloud for filtering. Updates App.tsx and GraphCanvas.tsx to integrate these features and their state management, and adds supporting hooks and components. Also improves keyboard navigation and sound effects integration. --- .../graph-viewer/.vite/deps/_metadata.json | 8 + packages/graph-viewer/.vite/deps/package.json | 3 + packages/graph-viewer/src/App.tsx | 474 ++++++++++++++- .../src/components/BookmarksPanel.tsx | 212 +++++++ .../src/components/EdgeParticles.tsx | 176 ++++++ .../src/components/GraphCanvas.tsx | 562 +++++++++++++++-- .../graph-viewer/src/components/Inspector.tsx | 24 +- .../src/components/LassoOverlay.tsx | 170 ++++++ .../graph-viewer/src/components/MiniMap.tsx | 200 ++++++ .../src/components/PathfindingOverlay.tsx | 173 ++++++ .../src/components/RadialMenu.tsx | 287 +++++++++ .../src/components/SelectionActions.tsx | 294 +++++++++ .../graph-viewer/src/components/TagCloud.tsx | 254 ++++++++ .../src/components/TimelineBar.tsx | 318 ++++++++++ .../src/components/settings/SettingsPanel.tsx | 44 +- .../graph-viewer/src/hooks/useBookmarks.ts | 127 ++++ .../graph-viewer/src/hooks/useCameraState.ts | 106 ++++ .../graph-viewer/src/hooks/useFocusMode.ts | 165 +++++ .../src/hooks/useKeyboardNavigation.ts | 67 +- .../src/hooks/useLassoSelection.ts | 248 ++++++++ .../graph-viewer/src/hooks/usePathfinding.ts | 347 +++++++++++ .../graph-viewer/src/hooks/useSoundEffects.ts | 122 ++++ .../graph-viewer/src/hooks/useTagCloud.ts | 182 ++++++ .../graph-viewer/src/hooks/useTimeTravel.ts | 306 ++++++++++ packages/graph-viewer/src/index.css | 11 + packages/graph-viewer/src/lib/sounds.ts | 570 ++++++++++++++++++ 26 files changed, 5395 insertions(+), 55 deletions(-) create mode 100644 packages/graph-viewer/.vite/deps/_metadata.json create mode 100644 packages/graph-viewer/.vite/deps/package.json create mode 100644 packages/graph-viewer/src/components/BookmarksPanel.tsx create mode 100644 packages/graph-viewer/src/components/EdgeParticles.tsx create mode 100644 packages/graph-viewer/src/components/LassoOverlay.tsx create mode 100644 packages/graph-viewer/src/components/MiniMap.tsx create mode 100644 packages/graph-viewer/src/components/PathfindingOverlay.tsx create mode 100644 packages/graph-viewer/src/components/RadialMenu.tsx create mode 100644 packages/graph-viewer/src/components/SelectionActions.tsx create mode 100644 packages/graph-viewer/src/components/TagCloud.tsx create mode 100644 packages/graph-viewer/src/components/TimelineBar.tsx create mode 100644 packages/graph-viewer/src/hooks/useBookmarks.ts create mode 100644 packages/graph-viewer/src/hooks/useCameraState.ts create mode 100644 packages/graph-viewer/src/hooks/useFocusMode.ts create mode 100644 packages/graph-viewer/src/hooks/useLassoSelection.ts create mode 100644 packages/graph-viewer/src/hooks/usePathfinding.ts create mode 100644 packages/graph-viewer/src/hooks/useSoundEffects.ts create mode 100644 packages/graph-viewer/src/hooks/useTagCloud.ts create mode 100644 packages/graph-viewer/src/hooks/useTimeTravel.ts create mode 100644 packages/graph-viewer/src/lib/sounds.ts diff --git a/packages/graph-viewer/.vite/deps/_metadata.json b/packages/graph-viewer/.vite/deps/_metadata.json new file mode 100644 index 0000000..7f230aa --- /dev/null +++ b/packages/graph-viewer/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "c60f2b02", + "configHash": "ce3392f8", + "lockfileHash": "081b15b3", + "browserHash": "c030f439", + "optimized": {}, + "chunks": {} +} diff --git a/packages/graph-viewer/.vite/deps/package.json b/packages/graph-viewer/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/packages/graph-viewer/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 0468888..87c0d8e 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { Settings } from 'lucide-react' // Build version - update this when making significant changes @@ -15,8 +15,20 @@ import { GestureDebugOverlay } from './components/GestureDebugOverlay' import { Hand2DOverlay } from './components/Hand2DOverlay' import { HandControlOverlay } from './components/HandControlOverlay' import { SettingsPanel } from './components/settings' +import { BookmarksPanel } from './components/BookmarksPanel' +import { PathfindingOverlay } from './components/PathfindingOverlay' +import { TimelineBar } from './components/TimelineBar' +import { RadialMenu } from './components/RadialMenu' +import { LassoOverlay } from './components/LassoOverlay' +import { SelectionActions } from './components/SelectionActions' +import { TagCloud } from './components/TagCloud' import { useHandLockAndGrab } from './hooks/useHandLockAndGrab' +import { useTagCloud } from './hooks/useTagCloud' import { useKeyboardNavigation } from './hooks/useKeyboardNavigation' +import { useBookmarks, type Bookmark } from './hooks/useBookmarks' +import { usePathfinding } from './hooks/usePathfinding' +import { useTimeTravel } from './hooks/useTimeTravel' +import { useSoundEffects } from './hooks/useSoundEffects' import type { GraphNode, FilterState, @@ -103,6 +115,49 @@ export default function App() { const [debugOverlayVisible, setDebugOverlayVisible] = useState(false) const [performanceMode, setPerformanceMode] = useState(false) const [settingsPanelOpen, setSettingsPanelOpen] = useState(false) + + // Focus/Spotlight mode state + const [focusModeEnabled, setFocusModeEnabled] = useState(false) + const [focusTransition, setFocusTransition] = useState(0) // 0-1 for smooth transition + const focusTransitionRef = useRef(0) + const focusAnimationRef = useRef(null) + + // Radial menu state + const [radialMenuState, setRadialMenuState] = useState<{ + isOpen: boolean + node: GraphNode | null + position: { x: number; y: number } + }>({ + isOpen: false, + node: null, + position: { x: 0, y: 0 }, + }) + + // Lasso selection state + const [lassoState, setLassoState] = useState<{ + isDrawing: boolean + points: { x: number; y: number }[] + selectedIds: Set + }>({ + isDrawing: false, + points: [], + selectedIds: new Set(), + }) + const canvasContainerRef = useRef(null) + const getNodesInPolygonRef = useRef<((polygon: { x: number; y: number }[]) => string[]) | null>(null) + + // Tag cloud state + const [tagCloudVisible, setTagCloudVisible] = useState(false) + + // Cleanup focus animation on unmount + useEffect(() => { + return () => { + if (focusAnimationRef.current) { + cancelAnimationFrame(focusAnimationRef.current) + } + } + }, []) + const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) // Tracking source - check URL param on mount, then allow UI toggle const [trackingSource, setTrackingSource] = useState<'mediapipe' | 'iphone'>(() => { @@ -159,6 +214,19 @@ export default function App() { // Reset view callback - will be set by GraphCanvas const [resetViewFn, setResetViewFn] = useState<(() => void) | null>(null) + // Bookmarks + const { + bookmarks, + addBookmark, + updateBookmark, + deleteBookmark, + getBookmarkByIndex, + } = useBookmarks() + + // Camera state and navigation for bookmarks + const [cameraStateForBookmarks, setCameraStateForBookmarks] = useState({ x: 0, y: 0, z: 150, zoom: 1 }) + const navigateForBookmarksRef = useRef<((x: number, y: number) => void) | null>(null) + const handleGestureStateChange = useCallback((state: GestureState) => { setGestureState(state) }, []) @@ -172,17 +240,206 @@ export default function App() { enabled: isAuthenticated, }) + // Tag Cloud + const tagCloud = useTagCloud({ + nodes: data?.nodes ?? [], + typeColors: data?.meta?.type_colors, + }) + + // Sound Effects + const sound = useSoundEffects() + + // Pathfinding + const pathfinding = usePathfinding({ + nodes: (data?.nodes ?? []) as any, + edges: data?.edges ?? [], + }) + + // Time Travel + const timeTravel = useTimeTravel({ + nodes: data?.nodes ?? [], + enabled: isAuthenticated, + }) + + // Play sound when time travel is activated + const prevTimeTravelActive = useRef(timeTravel.isActive) + useEffect(() => { + if (timeTravel.isActive !== prevTimeTravelActive.current) { + if (timeTravel.isActive) { + sound.playTimeTravel() + } + prevTimeTravelActive.current = timeTravel.isActive + } + }, [timeTravel.isActive, sound]) + + // Get source and target nodes for pathfinding overlay + const pathSourceNode = useMemo(() => { + if (!pathfinding.sourceId || !data?.nodes) return null + return (data.nodes as any[]).find(n => n.id === pathfinding.sourceId) ?? null + }, [pathfinding.sourceId, data?.nodes]) + + const pathTargetNode = useMemo(() => { + if (!pathfinding.targetId || !data?.nodes) return null + return (data.nodes as any[]).find(n => n.id === pathfinding.targetId) ?? null + }, [pathfinding.targetId, data?.nodes]) + + // Bookmark handlers (must be after data is defined) + const handleSaveBookmark = useCallback(() => { + addBookmark( + { x: cameraStateForBookmarks.x, y: cameraStateForBookmarks.y, z: cameraStateForBookmarks.z }, + cameraStateForBookmarks.zoom, + selectedNode?.id + ) + sound.playBookmark() + }, [addBookmark, cameraStateForBookmarks, selectedNode, sound]) + + const handleNavigateToBookmark = useCallback((bookmark: Bookmark) => { + navigateForBookmarksRef.current?.(bookmark.position.x, bookmark.position.y) + // If bookmark has a selected node, select it + if (bookmark.selectedNodeId && data?.nodes) { + const node = data.nodes.find(n => n.id === bookmark.selectedNodeId) + if (node) { + setSelectedNode(node) + } + } + }, [data?.nodes]) + + const handleRenameBookmark = useCallback((id: string, name: string) => { + updateBookmark(id, { name }) + }, [updateBookmark]) + + // Quick navigate to bookmark by number (1-9) + const handleQuickNavigate = useCallback((index: number) => { + const bookmark = getBookmarkByIndex(index) + if (bookmark) { + handleNavigateToBookmark(bookmark) + } + }, [getBookmarkByIndex, handleNavigateToBookmark]) + const handleNodeSelect = useCallback((node: GraphNode | null) => { + // If we're in path selection mode and a node is clicked, complete the path + if (pathfinding.isSelectingTarget && node) { + pathfinding.completePathSelection(node.id) + sound.playPathFound() + return + } + if (node) { + sound.playSelect(node.importance ?? 0.5) + } setSelectedNode(node) - }, []) + }, [pathfinding.isSelectingTarget, pathfinding.completePathSelection, sound]) const handleNodeHover = useCallback((node: GraphNode | null) => { + if (node) { + sound.playHover() + } setHoveredNode(node) + }, [sound]) + + // Radial menu handlers + const handleNodeContextMenu = useCallback((node: GraphNode, screenPosition: { x: number; y: number }) => { + setRadialMenuState({ + isOpen: true, + node, + position: screenPosition, + }) + setSelectedNode(node) // Also select the node + }, []) + + const handleCloseRadialMenu = useCallback(() => { + setRadialMenuState(prev => ({ + ...prev, + isOpen: false, + })) + }, []) + + const handleCopyNodeId = useCallback((nodeId: string) => { + // Could show a toast notification here + console.log('Copied node ID:', nodeId) + }, []) + + const handleViewNodeContent = useCallback((node: GraphNode) => { + // Select the node to show in inspector + setSelectedNode(node) + }, []) + + // Lasso selection handlers + const handleLassoStart = useCallback((x: number, y: number) => { + setLassoState(prev => ({ + ...prev, + isDrawing: true, + points: [{ x, y }], + })) + }, []) + + const handleLassoMove = useCallback((x: number, y: number) => { + setLassoState(prev => { + if (!prev.isDrawing) return prev + // Only add point if moved enough to avoid too many points + const lastPoint = prev.points[prev.points.length - 1] + if (lastPoint) { + const dist = Math.sqrt(Math.pow(x - lastPoint.x, 2) + Math.pow(y - lastPoint.y, 2)) + if (dist < 3) return prev + } + return { + ...prev, + points: [...prev.points, { x, y }], + } + }) + }, []) + + const handleLassoEnd = useCallback(() => { + setLassoState(prev => { + if (!prev.isDrawing || prev.points.length < 3) { + return { ...prev, isDrawing: false, points: [] } + } + + // Call GraphCanvas to find nodes in the polygon + const nodesInPolygon = getNodesInPolygonRef.current?.(prev.points) ?? [] + const newSelectedIds = new Set(prev.selectedIds) + nodesInPolygon.forEach(id => newSelectedIds.add(id)) + + // Play lasso sound if nodes were selected + if (nodesInPolygon.length > 0) { + sound.playLasso() + } + + return { + isDrawing: false, + points: [], + selectedIds: newSelectedIds, + } + }) + }, [sound]) + + const handleLassoCancel = useCallback(() => { + setLassoState(prev => ({ + ...prev, + isDrawing: false, + points: [], + })) + }, []) + + const handleClearLassoSelection = useCallback(() => { + setLassoState(prev => ({ + ...prev, + selectedIds: new Set(), + })) }, []) + // Get selected nodes from lasso + const lassoSelectedNodes = useMemo(() => { + if (!data?.nodes || lassoState.selectedIds.size === 0) return [] + return data.nodes.filter(n => lassoState.selectedIds.has(n.id)) + }, [data?.nodes, lassoState.selectedIds]) + const handleSearch = useCallback((term: string) => { + // Play search sound on typing (only if term changed and is not empty) + if (term.length > 0) { + sound.playSearch() + } setSearchTerm(term) - }, []) + }, [sound]) const handleFilterChange = useCallback((newFilters: Partial) => { setFilters(prev => ({ ...prev, ...newFilters })) @@ -216,7 +473,51 @@ export default function App() { setDisplayConfig(prev => ({ ...prev, showLabels: !prev.showLabels })) }, []) + // Focus mode toggle with smooth transition animation + const handleToggleFocusMode = useCallback(() => { + setFocusModeEnabled(prev => { + const newEnabled = !prev + + // Cancel any existing animation + if (focusAnimationRef.current) { + cancelAnimationFrame(focusAnimationRef.current) + } + + const startTime = performance.now() + const duration = 400 // 400ms transition + const startValue = focusTransitionRef.current + const endValue = newEnabled ? 1 : 0 + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime + const progress = Math.min(elapsed / duration, 1) + + // Ease out cubic for smooth deceleration + const eased = 1 - Math.pow(1 - progress, 3) + const newTransition = startValue + (endValue - startValue) * eased + + focusTransitionRef.current = newTransition + setFocusTransition(newTransition) + + if (progress < 1) { + focusAnimationRef.current = requestAnimationFrame(animate) + } else { + focusAnimationRef.current = null + } + } + + focusAnimationRef.current = requestAnimationFrame(animate) + return newEnabled + }) + }, []) + // Keyboard navigation + const handleStartPathfindingFromKeyboard = useCallback(() => { + if (selectedNode) { + pathfinding.startPathSelection(selectedNode.id) + } + }, [selectedNode, pathfinding.startPathSelection]) + const { shortcuts } = useKeyboardNavigation({ nodes: (data?.nodes ?? []) as any, selectedNode, @@ -224,12 +525,31 @@ export default function App() { onReheat: handleReheat, onToggleSettings: () => setSettingsPanelOpen(prev => !prev), onToggleLabels: handleToggleLabels, + onToggleFocus: handleToggleFocusMode, + onSaveBookmark: handleSaveBookmark, + onQuickNavigate: handleQuickNavigate, + onStartPathfinding: handleStartPathfindingFromKeyboard, + onCancelPathfinding: pathfinding.cancelPathSelection, + isPathSelecting: pathfinding.isSelectingTarget, enabled: true, }) // Log available shortcuts for debugging (remove in production) void shortcuts + // Toggle tag cloud with 'T' key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key === 't' || e.key === 'T') { + setTagCloudVisible(prev => !prev) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + if (!isAuthenticated) { return } @@ -260,6 +580,34 @@ export default function App() { {BUILD_VERSION} + {/* Focus/Spotlight Mode Toggle */} + + {/* Performance Mode Toggle */} + + {/* Panel content */} + {isExpanded && ( +
+ {/* Save button */} +
+ +
+ + {/* Bookmark list */} + {bookmarks.length === 0 ? ( +
+ No bookmarks yet. +
+ Press Cmd+B to save. +
+ ) : ( +
+ {bookmarks.map((bookmark, index) => ( +
+ {/* Quick access number (1-9) */} + {index < 9 && ( + + {index + 1} + + )} + {index >= 9 && } + + {/* Bookmark info */} +
+ {editingId === bookmark.id ? ( + setEditName(e.target.value)} + onBlur={handleSaveEdit} + onKeyDown={handleKeyDown} + autoFocus + className=" + w-full bg-slate-900/50 border border-slate-600 rounded px-2 py-1 + text-sm text-white focus:outline-none focus:border-blue-500 + " + /> + ) : ( + + )} +
+ z: {bookmark.zoom.toFixed(1)}x +
+
+ + {/* Actions */} +
+ + +
+
+ ))} +
+ )} + + {/* Footer hint */} + {bookmarks.length > 0 && ( +
+ Press 1-9 to quick navigate +
+ )} +
+ )} +
+ ) +} diff --git a/packages/graph-viewer/src/components/EdgeParticles.tsx b/packages/graph-viewer/src/components/EdgeParticles.tsx new file mode 100644 index 0000000..4629f6f --- /dev/null +++ b/packages/graph-viewer/src/components/EdgeParticles.tsx @@ -0,0 +1,176 @@ +/** + * EdgeParticles - Animated particles flowing along edges + * + * Creates a subtle, ambient effect where tiny particles flow along + * relationship edges. Speed and density correlate with edge strength. + */ + +import { useRef, useMemo } from 'react' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import type { GraphEdge, SimulationNode } from '../lib/types' + +interface EdgeParticlesProps { + edges: GraphEdge[] + nodes: SimulationNode[] + enabled?: boolean + particlesPerEdge?: number +} + +// Maximum particles to render for performance +const MAX_PARTICLES = 2000 +const PARTICLE_SIZE = 0.3 + +export function EdgeParticles({ + edges, + nodes, + enabled = true, + particlesPerEdge = 3, +}: EdgeParticlesProps) { + const pointsRef = useRef(null) + const progressRef = useRef(null) + const edgeDataRef = useRef<{ start: THREE.Vector3; end: THREE.Vector3; speed: number; color: THREE.Color }[]>([]) + + // Build node position lookup + const nodePositions = useMemo(() => { + const map = new Map() + nodes.forEach(node => { + map.set(node.id, new THREE.Vector3(node.x ?? 0, node.y ?? 0, node.z ?? 0)) + }) + return map + }, [nodes]) + + // Create particle geometry and initial positions + const { geometry, particleCount } = useMemo(() => { + if (!enabled || edges.length === 0) { + return { geometry: new THREE.BufferGeometry(), particleCount: 0 } + } + + // Limit total particles + const actualParticlesPerEdge = Math.min( + particlesPerEdge, + Math.floor(MAX_PARTICLES / edges.length) + ) + const count = Math.min(edges.length * actualParticlesPerEdge, MAX_PARTICLES) + + const positions = new Float32Array(count * 3) + const colors = new Float32Array(count * 3) + const progress = new Float32Array(count) + const edgeData: { start: THREE.Vector3; end: THREE.Vector3; speed: number; color: THREE.Color }[] = [] + + let particleIndex = 0 + + edges.forEach(edge => { + const startPos = nodePositions.get(edge.source) + const endPos = nodePositions.get(edge.target) + + if (!startPos || !endPos) return + + // Parse edge color + const edgeColor = new THREE.Color(edge.color || '#666666') + + for (let i = 0; i < actualParticlesPerEdge && particleIndex < count; i++) { + // Random starting progress along edge + const p = Math.random() + progress[particleIndex] = p + + // Store edge data for this particle + edgeData.push({ + start: startPos.clone(), + end: endPos.clone(), + speed: 0.15 + edge.strength * 0.25, // Stronger = faster + color: edgeColor, + }) + + // Set initial position + const x = startPos.x + (endPos.x - startPos.x) * p + const y = startPos.y + (endPos.y - startPos.y) * p + const z = startPos.z + (endPos.z - startPos.z) * p + + positions[particleIndex * 3] = x + positions[particleIndex * 3 + 1] = y + positions[particleIndex * 3 + 2] = z + + // Set color with some alpha variation + colors[particleIndex * 3] = edgeColor.r + colors[particleIndex * 3 + 1] = edgeColor.g + colors[particleIndex * 3 + 2] = edgeColor.b + + particleIndex++ + } + }) + + progressRef.current = progress + edgeDataRef.current = edgeData + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)) + + return { geometry: geo, particleCount: particleIndex } + }, [edges, nodePositions, enabled, particlesPerEdge]) + + // Animate particles along edges + useFrame((_, delta) => { + if (!enabled || !pointsRef.current || !progressRef.current || particleCount === 0) return + + const positions = pointsRef.current.geometry.attributes.position + const progress = progressRef.current + const edgeData = edgeDataRef.current + + for (let i = 0; i < particleCount; i++) { + // Update progress + progress[i] += delta * edgeData[i].speed + + // Loop back when reaching the end + if (progress[i] > 1) { + progress[i] = progress[i] % 1 + } + + // Interpolate position along edge + const p = progress[i] + const { start, end } = edgeData[i] + + positions.array[i * 3] = start.x + (end.x - start.x) * p + positions.array[i * 3 + 1] = start.y + (end.y - start.y) * p + positions.array[i * 3 + 2] = start.z + (end.z - start.z) * p + } + + positions.needsUpdate = true + }) + + // Update edge data when nodes move + useMemo(() => { + if (edgeDataRef.current.length === 0) return + + let edgeIndex = 0 + edges.forEach(edge => { + const startPos = nodePositions.get(edge.source) + const endPos = nodePositions.get(edge.target) + + if (!startPos || !endPos) return + + for (let i = 0; i < particlesPerEdge && edgeIndex < edgeDataRef.current.length; i++) { + edgeDataRef.current[edgeIndex].start.copy(startPos) + edgeDataRef.current[edgeIndex].end.copy(endPos) + edgeIndex++ + } + }) + }, [nodePositions, edges, particlesPerEdge]) + + if (!enabled || particleCount === 0) return null + + return ( + + + + ) +} diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 21565aa..029ed71 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -39,9 +39,12 @@ import type { } from '../lib/types' import { DEFAULT_FORCE_CONFIG, DEFAULT_DISPLAY_CONFIG, DEFAULT_CLUSTER_CONFIG, DEFAULT_RELATIONSHIP_VISIBILITY } from '../lib/types' import { useClusterDetection } from '../hooks/useClusterDetection' +import { useFocusMode, type NodeFocusState } from '../hooks/useFocusMode' import { ClusterBoundaries } from './ClusterBoundaries' import { SelectionHighlight, ConnectedPathsHighlight } from './SelectionHighlight' import { getEdgeStyle } from '../lib/edgeStyles' +import { EdgeParticles } from './EdgeParticles' +import { MiniMap } from './MiniMap' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' import { findNodeHit, type NodeSphere, type NodeHit } from '../hooks/useStablePointerRay' @@ -77,6 +80,7 @@ interface GraphCanvasProps { searchTerm: string onNodeSelect: (node: GraphNode | null) => void onNodeHover: (node: GraphNode | null) => void + onNodeContextMenu?: (node: GraphNode, screenPosition: { x: number; y: number }) => void gestureControlEnabled?: boolean trackingSource?: 'mediapipe' | 'iphone' onGestureStateChange?: (state: GestureState) => void @@ -97,6 +101,26 @@ interface GraphCanvasProps { typeColors?: Record onReheatReady?: (reheat: () => void) => void onResetViewReady?: (resetView: () => void) => void + focusModeEnabled?: boolean + focusTransition?: number + // Bookmarks: expose camera state and navigation to parent + onCameraStateForBookmarks?: (state: { x: number; y: number; z: number; zoom: number }) => void + onNavigateForBookmarks?: (fn: (x: number, y: number, z?: number) => void) => void + // Pathfinding: highlight path nodes and edges + pathNodeIds?: Set + pathEdgeKeys?: Set + pathSourceId?: string | null + pathTargetId?: string | null + isPathSelecting?: boolean + // Time Travel: filter nodes by timestamp + timeTravelActive?: boolean + timeTravelVisibleNodes?: Set + // Lasso selection + onGetNodesInPolygon?: (fn: (polygon: { x: number; y: number }[]) => string[]) => void + lassoSelectedIds?: Set + // Tag cloud filtering + tagFilteredNodeIds?: Set + hasTagFilter?: boolean } export function GraphCanvas({ @@ -107,6 +131,7 @@ export function GraphCanvas({ searchTerm, onNodeSelect, onNodeHover, + onNodeContextMenu, gestureControlEnabled = false, trackingSource: source = 'mediapipe', onGestureStateChange, @@ -119,7 +144,42 @@ export function GraphCanvas({ typeColors = {}, onReheatReady, onResetViewReady, + focusModeEnabled = false, + focusTransition = 0, + onCameraStateForBookmarks, + onNavigateForBookmarks, + pathNodeIds, + pathEdgeKeys, + pathSourceId, + pathTargetId, + isPathSelecting, + timeTravelActive = false, + timeTravelVisibleNodes, + onGetNodesInPolygon, + lassoSelectedIds, + tagFilteredNodeIds, + hasTagFilter = false, }: GraphCanvasProps) { + // MiniMap state + const [cameraState, setCameraState] = useState({ x: 0, y: 0, z: 150, zoom: 1 }) + const [layoutNodesForMiniMap, setLayoutNodesForMiniMap] = useState([]) + const navigateToRef = useRef<((x: number, y: number) => void) | null>(null) + + const handleMiniMapNavigate = useCallback((x: number, y: number) => { + navigateToRef.current?.(x, y) + }, []) + + // Forward camera state to parent for bookmarks + useEffect(() => { + onCameraStateForBookmarks?.(cameraState) + }, [cameraState, onCameraStateForBookmarks]) + + // Callback to capture and expose navigation function + const handleNavigateToReady = useCallback((fn: (x: number, y: number) => void) => { + navigateToRef.current = fn + onNavigateForBookmarks?.(fn) + }, [onNavigateForBookmarks]) + // Get iPhone WebSocket URL (from URL param or default) const iphoneUrl = useIPhoneUrl() @@ -167,40 +227,91 @@ export function GraphCanvas({ }, [onTrackingInfoChange, source, iphoneUrl, iphoneConnected, hasLiDAR, phoneConnected, bridgeIps, phonePort]) return ( - - + + + + + {/* MiniMap Navigator */} + 0} + size={140} /> - +
) } -interface SceneProps extends Omit { +interface SceneProps extends Omit { + onNodeContextMenu?: (node: GraphNode, screenPosition: { x: number; y: number }) => void gestureState: GestureState gestureControlEnabled: boolean performanceMode: boolean onResetViewReady?: (resetView: () => void) => void + focusModeEnabled: boolean + focusTransition: number + onCameraStateChange?: (state: { x: number; y: number; z: number; zoom: number }) => void + onLayoutNodesChange?: (nodes: SimulationNode[]) => void + onNavigateToReady?: (fn: (x: number, y: number) => void) => void + // Pathfinding + pathNodeIds?: Set + pathEdgeKeys?: Set + pathSourceId?: string | null + pathTargetId?: string | null + isPathSelecting?: boolean + // Time Travel + timeTravelActive?: boolean + timeTravelVisibleNodes?: Set + // Lasso selection + onGetNodesInPolygon?: (fn: (polygon: { x: number; y: number }[]) => string[]) => void + lassoSelectedIds?: Set + // Tag cloud filtering + tagFilteredNodeIds?: Set + hasTagFilter?: boolean } function Scene({ @@ -211,6 +322,7 @@ function Scene({ searchTerm, onNodeSelect, onNodeHover, + onNodeContextMenu, gestureState, gestureControlEnabled, performanceMode, @@ -221,9 +333,35 @@ function Scene({ typeColors = {}, onReheatReady, onResetViewReady, + focusModeEnabled, + focusTransition, + onCameraStateChange, + onLayoutNodesChange, + onNavigateToReady, + pathNodeIds, + pathEdgeKeys, + pathSourceId, + pathTargetId, + isPathSelecting: _isPathSelecting, + timeTravelActive = false, + timeTravelVisibleNodes, + onGetNodesInPolygon, + lassoSelectedIds, + tagFilteredNodeIds, + hasTagFilter = false, }: SceneProps) { + const { camera } = useThree() const { nodes: layoutNodes, isSimulating, reheat } = useForceLayout({ nodes, edges, forceConfig }) + // Focus mode - compute depth-based opacity for spotlight effect + const focusStates = useFocusMode( + layoutNodes, + edges, + selectedNode?.id ?? null, + focusModeEnabled, + focusTransition + ) + // Cluster detection const clusters = useClusterDetection({ nodes: layoutNodes, @@ -255,10 +393,129 @@ function Scene({ onResetViewReady(resetView) } }, [onResetViewReady, resetView]) + + // MiniMap: Send layout nodes when they change + useEffect(() => { + onLayoutNodesChange?.(layoutNodes) + }, [layoutNodes, onLayoutNodesChange]) + + // MiniMap: Navigate to function + const navigateTo = useCallback((x: number, y: number) => { + if (controlsRef.current) { + // Smoothly animate the OrbitControls target + const controls = controlsRef.current + const startTarget = controls.target.clone() + const endTarget = new THREE.Vector3(x, y, 0) + const startTime = performance.now() + const duration = 400 + + const animate = () => { + const elapsed = performance.now() - startTime + const progress = Math.min(elapsed / duration, 1) + const eased = 1 - Math.pow(1 - progress, 3) // ease out cubic + + controls.target.lerpVectors(startTarget, endTarget, eased) + controls.update() + + if (progress < 1) { + requestAnimationFrame(animate) + } + } + requestAnimationFrame(animate) + } + }, []) + + useEffect(() => { + onNavigateToReady?.(navigateTo) + }, [navigateTo, onNavigateToReady]) + + // Get nodes inside a screen-space polygon (for lasso selection) + const getNodesInPolygon = useCallback((polygon: { x: number; y: number }[]) => { + if (polygon.length < 3) return [] + + // Get the canvas size from the renderer + const canvas = document.querySelector('canvas') + if (!canvas) return [] + const rect = canvas.getBoundingClientRect() + + // Point-in-polygon test using ray casting + const isPointInPolygon = (point: { x: number; y: number }) => { + let inside = false + const n = polygon.length + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = polygon[i].x + const yi = polygon[i].y + const xj = polygon[j].x + const yj = polygon[j].y + if (yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi) { + inside = !inside + } + } + return inside + } + + // Project each node to screen space and check if inside polygon + const result: string[] = [] + layoutNodes.forEach((node) => { + const worldPos = new THREE.Vector3(node.x ?? 0, node.y ?? 0, node.z ?? 0) + const projected = worldPos.project(camera) + const screenX = ((projected.x + 1) / 2) * rect.width + const screenY = ((-projected.y + 1) / 2) * rect.height + + if (isPointInPolygon({ x: screenX, y: screenY })) { + result.push(node.id) + } + }) + + return result + }, [layoutNodes, camera]) + + // Expose getNodesInPolygon to parent + useEffect(() => { + onGetNodesInPolygon?.(getNodesInPolygon) + }, [getNodesInPolygon, onGetNodesInPolygon]) + const [autoRotate, setAutoRotate] = useState(false) const groupRef = useRef(null) const controlsRef = useRef(null) - const { camera } = useThree() + + // MiniMap: Track camera state and update periodically + const lastCameraUpdateRef = useRef(0) + const lastCameraPosRef = useRef({ x: 0, y: 0, z: 150 }) + useFrame(() => { + if (!onCameraStateChange) return + + const now = performance.now() + // Only update every 100ms to avoid excessive rerenders + if (now - lastCameraUpdateRef.current < 100) return + + // Get camera position (accounting for OrbitControls target) + const target = controlsRef.current?.target ?? new THREE.Vector3(0, 0, 0) + const pos = { x: target.x, y: target.y, z: camera.position.z } + + // Check if position changed significantly + const lastPos = lastCameraPosRef.current + const dist = Math.sqrt( + Math.pow(pos.x - lastPos.x, 2) + + Math.pow(pos.y - lastPos.y, 2) + + Math.pow(pos.z - lastPos.z, 2) + ) + + if (dist > 0.5) { + lastCameraPosRef.current = pos + lastCameraUpdateRef.current = now + + // Calculate zoom from camera distance + const zoom = 150 / Math.max(camera.position.z, 10) + + onCameraStateChange({ + x: pos.x, + y: pos.y, + z: pos.z, + zoom, + }) + } + }) // Expanded node state (for the bloom animation) const [expandedNodeId, setExpandedNodeId] = useState(null) @@ -657,6 +914,20 @@ function Scene({ relationshipVisibility={relationshipVisibility} linkThickness={displayConfig.linkThickness} linkOpacity={displayConfig.linkOpacity} + focusStates={focusStates} + pathEdgeKeys={pathEdgeKeys} + timeTravelActive={timeTravelActive} + timeTravelVisibleNodes={timeTravelVisibleNodes} + tagFilteredNodeIds={tagFilteredNodeIds} + hasTagFilter={hasTagFilter} + /> + + {/* Ambient edge particles - flowing along edges */} + {/* Instanced nodes - single draw call for all nodes */} @@ -669,7 +940,17 @@ function Scene({ connectedIds={connectedIds} onNodeSelect={onNodeSelect} onNodeHover={onNodeHover} + onNodeContextMenu={onNodeContextMenu} nodeSizeScale={displayConfig.nodeSizeScale} + focusStates={focusStates} + pathNodeIds={pathNodeIds} + pathSourceId={pathSourceId} + pathTargetId={pathTargetId} + timeTravelActive={timeTravelActive} + timeTravelVisibleNodes={timeTravelVisibleNodes} + lassoSelectedIds={lassoSelectedIds} + tagFilteredNodeIds={tagFilteredNodeIds} + hasTagFilter={hasTagFilter} /> {/* Selection highlight - glowing ring around selected node */} @@ -790,6 +1071,12 @@ interface BatchedEdgesProps { relationshipVisibility: RelationshipVisibility linkThickness: number linkOpacity: number + focusStates: Map + pathEdgeKeys?: Set + timeTravelActive?: boolean + timeTravelVisibleNodes?: Set + tagFilteredNodeIds?: Set + hasTagFilter?: boolean } function BatchedEdges({ @@ -800,6 +1087,12 @@ function BatchedEdges({ relationshipVisibility, linkThickness, linkOpacity, + focusStates, + pathEdgeKeys, + timeTravelActive = false, + timeTravelVisibleNodes, + tagFilteredNodeIds, + hasTagFilter = false, }: BatchedEdgesProps) { const lineRef = useRef(null) @@ -817,16 +1110,39 @@ function BatchedEdges({ const targetNode = nodeById.get(edge.target) if (!sourceNode || !targetNode) return + // Time Travel: hide edges when either endpoint is outside time window + if (timeTravelActive && timeTravelVisibleNodes) { + const sourceVisible = timeTravelVisibleNodes.has(edge.source) + const targetVisible = timeTravelVisibleNodes.has(edge.target) + if (!sourceVisible || !targetVisible) return + } + + // Tag filtering: dim edges when both endpoints are not in the filtered set + // Only hide if BOTH are outside the filter to keep edges from matching nodes visible + if (hasTagFilter && tagFilteredNodeIds) { + const sourceInFilter = tagFilteredNodeIds.has(edge.source) + const targetInFilter = tagFilteredNodeIds.has(edge.target) + if (!sourceInFilter && !targetInFilter) return + } + visibleCount++ + // Check if this edge is part of the pathfinding result + const edgeKey1 = `${edge.source}-${edge.target}` + const edgeKey2 = `${edge.target}-${edge.source}` + const isInPath = pathEdgeKeys?.has(edgeKey1) || pathEdgeKeys?.has(edgeKey2) + const hasActivePath = pathEdgeKeys && pathEdgeKeys.size > 0 + const isHighlighted = selectedNode && (edge.source === selectedNode.id || edge.target === selectedNode.id) const isDimmed = - selectedNode && + (selectedNode && !connectedIds.has(edge.source) && - !connectedIds.has(edge.target) + !connectedIds.has(edge.target)) || + // Dim non-path edges when path is active + (hasActivePath && !isInPath) // Source vertex positions.push(sourceNode.x ?? 0, sourceNode.y ?? 0, sourceNode.z ?? 0) @@ -836,12 +1152,22 @@ function BatchedEdges({ // Get style for this edge type const style = getEdgeStyle(edge.type) - // Use style color instead of edge.color - const color = new THREE.Color(style.color) + // Use style color, or bright cyan for path edges + const color = isInPath + ? new THREE.Color('#00d4ff') // Bright electric cyan for path + : new THREE.Color(style.color) + + // Get focus mode opacity for both endpoints (use minimum) + const sourceFocus = focusStates.get(edge.source)?.opacity ?? 1 + const targetFocus = focusStates.get(edge.target)?.opacity ?? 1 + const focusOpacity = Math.min(sourceFocus, targetFocus) // Calculate alpha based on state and style - let alpha = style.opacity * linkOpacity - if (isDimmed) { + let alpha = style.opacity * linkOpacity * focusOpacity + if (isInPath) { + // Path edges are always bright + alpha = 1.0 + } else if (isDimmed) { alpha *= 0.1 } else if (isHighlighted) { alpha = Math.min(1, alpha * 1.5) @@ -861,7 +1187,7 @@ function BatchedEdges({ colors: new Float32Array(colors), visibleCount, } - }, [edges, nodeById, selectedNode, connectedIds, relationshipVisibility, linkOpacity]) + }, [edges, nodeById, selectedNode, connectedIds, relationshipVisibility, linkOpacity, focusStates, pathEdgeKeys, timeTravelActive, timeTravelVisibleNodes, tagFilteredNodeIds, hasTagFilter]) // Update geometry when positions/colors change useEffect(() => { @@ -903,7 +1229,17 @@ interface InstancedNodesProps { connectedIds: Set onNodeSelect: (node: GraphNode | null) => void onNodeHover: (node: GraphNode | null) => void + onNodeContextMenu?: (node: GraphNode, screenPosition: { x: number; y: number }) => void nodeSizeScale?: number + focusStates: Map + pathNodeIds?: Set + pathSourceId?: string | null + pathTargetId?: string | null + timeTravelActive?: boolean + timeTravelVisibleNodes?: Set + lassoSelectedIds?: Set + tagFilteredNodeIds?: Set + hasTagFilter?: boolean } function InstancedNodes({ @@ -915,11 +1251,33 @@ function InstancedNodes({ connectedIds, onNodeSelect, onNodeHover, + onNodeContextMenu, nodeSizeScale = 1.0, + focusStates, + pathNodeIds, + pathSourceId, + pathTargetId, + timeTravelActive = false, + timeTravelVisibleNodes, + lassoSelectedIds, + tagFilteredNodeIds, + hasTagFilter = false, }: InstancedNodesProps) { const meshRef = useRef(null) const { camera, raycaster, pointer } = useThree() + // Refs to hold latest time travel state (needed for useFrame closure) + const timeTravelActiveRef = useRef(timeTravelActive) + const timeTravelVisibleNodesRef = useRef(timeTravelVisibleNodes) + timeTravelActiveRef.current = timeTravelActive + timeTravelVisibleNodesRef.current = timeTravelVisibleNodes + + // Refs for tag filtering state (needed for useFrame closure) + const hasTagFilterRef = useRef(hasTagFilter) + const tagFilteredNodeIdsRef = useRef(tagFilteredNodeIds) + hasTagFilterRef.current = hasTagFilter + tagFilteredNodeIdsRef.current = tagFilteredNodeIds + // Shared geometry and material - created once const geometry = useMemo(() => new THREE.SphereGeometry(1, SPHERE_SEGMENTS, SPHERE_SEGMENTS), []) const material = useMemo( @@ -962,13 +1320,49 @@ function InstancedNodes({ const isSelected = selectedNode?.id === node.id const isHovered = hoveredNode?.id === node.id const isSearchMatch = !!searchTerm && matchingIds.has(node.id) + const isLassoSelected = lassoSelectedIds?.has(node.id) ?? false + + // Pathfinding state + const isPathSource = pathSourceId === node.id + const isPathTarget = pathTargetId === node.id + const isInPath = pathNodeIds?.has(node.id) ?? false + const hasActivePath = pathNodeIds && pathNodeIds.size > 0 + + // Time Travel visibility - hide nodes outside the time window (use refs for fresh values) + const isVisibleInTimeTravel = !timeTravelActiveRef.current || (timeTravelVisibleNodesRef.current?.has(node.id) ?? true) + + // Tag cloud filtering - use refs for fresh values + const isMatchingTagFilter = !hasTagFilterRef.current || (tagFilteredNodeIdsRef.current?.has(node.id) ?? true) + const isDimmed = !!( (selectedNode && !connectedIds.has(node.id)) || - (searchTerm && !matchingIds.has(node.id)) + (searchTerm && !matchingIds.has(node.id)) || + // Dim non-path nodes when path is active + (hasActivePath && !isInPath) || + // Dim nodes not matching tag filter + (hasTagFilterRef.current && !isMatchingTagFilter) ) - // Target scale based on state - const targetScale = isSelected ? 1.5 : isHovered ? 1.2 : 1 + // Get focus mode opacity + const focusOpacity = focusStates.get(node.id)?.opacity ?? 1 + + // Target scale based on state - path nodes get a size boost + // Time travel: nodes outside the time window scale to 0 + let targetScale: number + if (!isVisibleInTimeTravel) { + targetScale = 0 // Hide node by scaling to 0 + } else { + targetScale = isSelected ? 1.5 : isHovered ? 1.2 : 1 + if (isPathSource || isPathTarget) { + targetScale = Math.max(targetScale, 1.4) + } else if (isInPath) { + targetScale = Math.max(targetScale, 1.2) + } + // Lasso selected nodes get a slight boost + if (isLassoSelected && !isSelected) { + targetScale = Math.max(targetScale, 1.15) + } + } targetScalesRef.current[i] = targetScale // Smooth scale animation @@ -976,12 +1370,31 @@ function InstancedNodes({ const newScale = THREE.MathUtils.lerp(currentScale, targetScale, delta * 10) scalesRef.current[i] = newScale - // Apply pulsing for search matches + // Apply pulsing for search matches, path nodes, and lasso selected let finalScale = newScale if (isSearchMatch) { const pulse = 1 + Math.sin(performance.now() * 0.004) * 0.15 finalScale *= pulse } + if (isInPath && !isPathSource && !isPathTarget) { + // Subtle pulse for intermediate path nodes + const pulse = 1 + Math.sin(performance.now() * 0.003) * 0.08 + finalScale *= pulse + } + if (isLassoSelected && !isSelected) { + // Gentle pulse for lasso selected nodes + const pulse = 1 + Math.sin(performance.now() * 0.0025) * 0.06 + finalScale *= pulse + } + + // Node breathing - ambient pulse based on importance + // Phase offset based on node ID to prevent synchronized breathing + const nodePhase = (node.id.charCodeAt(0) + node.id.charCodeAt(node.id.length - 1)) * 0.1 + const breathingSpeed = 0.6 + node.importance * 0.2 // Faster for important nodes + const breathingAmplitude = 0.015 + node.importance * 0.025 // Bigger pulse for important nodes + const breathingTime = performance.now() * 0.001 * breathingSpeed + const breathing = 1 + Math.sin(breathingTime + nodePhase) * breathingAmplitude + finalScale *= breathing // Set position and scale (apply nodeSizeScale) tempPosition.set(node.x ?? 0, node.y ?? 0, node.z ?? 0) @@ -989,13 +1402,46 @@ function InstancedNodes({ tempMatrix.compose(tempPosition, tempQuaternion, tempScale) mesh.setMatrixAt(i, tempMatrix) - // Set color with dimming - tempColor.set(node.color) - if (isDimmed) { + // Set color with special handling for path nodes and lasso selection + if (isPathSource) { + // Source node: bright green + tempColor.set('#22c55e') + } else if (isPathTarget) { + // Target node: bright red/orange + tempColor.set('#ef4444') + } else if (isInPath) { + // Intermediate path nodes: electric cyan + tempColor.set('#00d4ff') + } else if (isLassoSelected) { + // Lasso selected nodes: blue tint + tempColor.set(node.color) + // Add blue tint by lerping toward blue + const blueColor = new THREE.Color('#3b82f6') + tempColor.lerp(blueColor, 0.35) + } else { + // Normal node color + tempColor.set(node.color) + } + + if (isDimmed && !isInPath && !isLassoSelected) { tempColor.multiplyScalar(0.15) - } else if (isSelected || isHovered || isSearchMatch) { - // Brighten selected/hovered nodes - tempColor.multiplyScalar(1.2) + } else if (isSelected || isHovered || isSearchMatch || isInPath || isLassoSelected) { + // Brighten selected/hovered/path/lasso nodes + tempColor.multiplyScalar(isInPath ? 1.3 : isLassoSelected ? 1.15 : 1.2) + } else { + // Recent nodes glow brighter - subtle pulsing brightness + const nodeTimestamp = node.timestamp ? new Date(node.timestamp).getTime() : 0 + const daysSinceCreation = (Date.now() - nodeTimestamp) / (1000 * 60 * 60 * 24) + if (daysSinceCreation < 7) { + // Nodes within last 7 days get a subtle brightness boost + const recentnessFactor = 1 - (daysSinceCreation / 7) // 1 for brand new, 0 for 7 days old + const glowPulse = 1 + Math.sin(performance.now() * 0.002 + nodePhase) * 0.1 * recentnessFactor + tempColor.multiplyScalar(1 + recentnessFactor * 0.15 * glowPulse) + } + } + // Apply focus mode opacity (but don't dim path or lasso selected nodes) + if (!isInPath && !isLassoSelected) { + tempColor.multiplyScalar(focusOpacity) } mesh.setColorAt(i, tempColor) }) @@ -1051,12 +1497,42 @@ function InstancedNodes({ [camera, pointer, raycaster, nodeIndexMap, onNodeSelect, selectedNode] ) + const handleContextMenu = useCallback( + (event: ThreeEvent) => { + if (!meshRef.current || !onNodeContextMenu) return + + // Prevent the browser's default context menu + event.nativeEvent.preventDefault() + + raycaster.setFromCamera(pointer, camera) + const intersects = raycaster.intersectObject(meshRef.current) + + if (intersects.length > 0) { + const instanceId = intersects[0].instanceId + if (instanceId !== undefined) { + const node = nodeIndexMap.get(instanceId) + if (node) { + event.stopPropagation() + // Get screen position from the native event + const screenPosition = { + x: event.nativeEvent.clientX, + y: event.nativeEvent.clientY, + } + onNodeContextMenu(node, screenPosition) + } + } + } + }, + [camera, pointer, raycaster, nodeIndexMap, onNodeContextMenu] + ) + return ( ) diff --git a/packages/graph-viewer/src/components/Inspector.tsx b/packages/graph-viewer/src/components/Inspector.tsx index 28f7a93..ce9985d 100644 --- a/packages/graph-viewer/src/components/Inspector.tsx +++ b/packages/graph-viewer/src/components/Inspector.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { X, Clock, Tag, ArrowRight, Sparkles, Edit2, Save, Trash2 } from 'lucide-react' +import { X, Clock, Tag, ArrowRight, Sparkles, Edit2, Save, Trash2, Route } from 'lucide-react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useGraphNeighbors } from '../hooks/useGraphData' import { updateMemory, deleteMemory } from '../api/client' @@ -9,9 +9,11 @@ interface InspectorProps { node: GraphNode | null onClose: () => void onNavigate: (node: GraphNode) => void + onStartPathfinding?: (nodeId: string) => void + isPathSelecting?: boolean } -export function Inspector({ node, onClose, onNavigate }: InspectorProps) { +export function Inspector({ node, onClose, onNavigate, onStartPathfinding, isPathSelecting }: InspectorProps) { const [isEditing, setIsEditing] = useState(false) const [editedImportance, setEditedImportance] = useState(0) const queryClient = useQueryClient() @@ -288,7 +290,23 @@ export function Inspector({ node, onClose, onNavigate }: InspectorProps) {
{/* Footer Actions */} -
+
+ {/* Find Path Button */} + {onStartPathfinding && ( + + )} + +
+
+
+ ) + } + + // Show path info when path is found + if (currentPath && sourceNode && targetNode) { + return ( +
+
+ {/* Header */} +
+
+
+ + + + Path Found +
+ +
+
+ + {/* Path details */} +
+ {/* Source and Target */} +
+ FROM + {truncate(sourceNode.content, 25)} +
+
+ TO + {truncate(targetNode.content, 25)} +
+ + {/* Stats */} +
+
+
{currentPath.hopCount}
+
Hops
+
+
+
{(currentPath.totalStrength * 100).toFixed(0)}%
+
Avg Strength
+
+
+
+ {getUniqueTypes(currentPath).join(' → ')} +
+
Relationship types
+
+
+
+ + {/* Alternative paths navigation */} + {pathCount > 1 && ( +
+ + + Path {activePath + 1} of {pathCount} + + +
+ )} +
+
+ ) + } + + return null +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength) + '...' +} + +function getUniqueTypes(path: PathResult): string[] { + const types: string[] = [] + for (const step of path.path) { + if (step.edgeType && !types.includes(step.edgeType)) { + types.push(step.edgeType) + } + } + return types.length > 0 ? types : ['—'] +} diff --git a/packages/graph-viewer/src/components/RadialMenu.tsx b/packages/graph-viewer/src/components/RadialMenu.tsx new file mode 100644 index 0000000..bf0337a --- /dev/null +++ b/packages/graph-viewer/src/components/RadialMenu.tsx @@ -0,0 +1,287 @@ +/** + * RadialMenu - Quick actions menu that appears on right-click + * + * Features: + * - 8 action items arranged in a circle + * - Smooth expand animation from center + * - Icons scale in sequentially + * - Hover enlarges items + * - Click outside or press Escape to close + */ + +import { useEffect, useCallback, useState } from 'react' +import { + Search, + Sun, + Route, + Plus, + Pencil, + FileText, + Copy, + Trash2, + X, +} from 'lucide-react' +import type { GraphNode } from '../lib/types' + +interface RadialMenuItem { + id: string + icon: React.ReactNode + label: string + color: string + action: () => void + disabled?: boolean +} + +interface RadialMenuProps { + node: GraphNode + position: { x: number; y: number } + onClose: () => void + onFindSimilar?: (node: GraphNode) => void + onToggleFocus?: () => void + onStartPath?: (nodeId: string) => void + onAddToSelection?: (node: GraphNode) => void + onEdit?: (node: GraphNode) => void + onViewContent?: (node: GraphNode) => void + onCopyId?: (nodeId: string) => void + onDelete?: (node: GraphNode) => void + focusModeEnabled?: boolean +} + +export function RadialMenu({ + node, + position, + onClose, + onFindSimilar, + onToggleFocus, + onStartPath, + onAddToSelection, + onEdit, + onViewContent, + onCopyId, + onDelete, + focusModeEnabled = false, +}: RadialMenuProps) { + const [isOpen, setIsOpen] = useState(false) + const [hoveredItem, setHoveredItem] = useState(null) + + // Animate open on mount + useEffect(() => { + const timer = setTimeout(() => setIsOpen(true), 10) + return () => clearTimeout(timer) + }, []) + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + // Handle click outside + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + }, + [onClose] + ) + + // Copy ID to clipboard + const handleCopyId = useCallback(() => { + navigator.clipboard.writeText(node.id) + onCopyId?.(node.id) + onClose() + }, [node.id, onCopyId, onClose]) + + // Menu items arranged in a circle (8 positions, starting from top) + const menuItems: RadialMenuItem[] = [ + { + id: 'find-similar', + icon: , + label: 'Find Similar', + color: 'from-blue-500 to-cyan-500', + action: () => { + onFindSimilar?.(node) + onClose() + }, + }, + { + id: 'focus', + icon: , + label: focusModeEnabled ? 'Exit Focus' : 'Focus Mode', + color: focusModeEnabled ? 'from-amber-500 to-yellow-500' : 'from-amber-400 to-orange-500', + action: () => { + onToggleFocus?.() + onClose() + }, + }, + { + id: 'start-path', + icon: , + label: 'Find Path To...', + color: 'from-cyan-500 to-teal-500', + action: () => { + onStartPath?.(node.id) + onClose() + }, + }, + { + id: 'add-selection', + icon: , + label: 'Add to Selection', + color: 'from-green-500 to-emerald-500', + action: () => { + onAddToSelection?.(node) + onClose() + }, + disabled: true, // TODO: Implement multi-selection + }, + { + id: 'view-content', + icon: , + label: 'View Content', + color: 'from-purple-500 to-violet-500', + action: () => { + onViewContent?.(node) + onClose() + }, + }, + { + id: 'edit', + icon: , + label: 'Edit Memory', + color: 'from-indigo-500 to-blue-500', + action: () => { + onEdit?.(node) + onClose() + }, + disabled: true, // TODO: Implement edit + }, + { + id: 'copy-id', + icon: , + label: 'Copy ID', + color: 'from-slate-500 to-gray-500', + action: handleCopyId, + }, + { + id: 'delete', + icon: , + label: 'Delete', + color: 'from-red-500 to-rose-500', + action: () => { + onDelete?.(node) + onClose() + }, + disabled: true, // TODO: Implement delete with confirmation + }, + ] + + // Calculate position for each item (arranged in a circle) + const radius = 80 // Distance from center + const getItemPosition = (index: number, total: number) => { + // Start from top (-90 degrees) and go clockwise + const angle = ((index / total) * 360 - 90) * (Math.PI / 180) + return { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + } + } + + return ( +
+ {/* Menu container positioned at click location */} +
+ {/* Center button (close) */} + + + {/* Radial menu items */} + {menuItems.map((item, index) => { + const pos = getItemPosition(index, menuItems.length) + const isHovered = hoveredItem === item.id + const delay = index * 30 // Staggered animation + + return ( + + ) + })} + + {/* Tooltip for hovered item */} + {hoveredItem && ( +
+ {menuItems.find((i) => i.id === hoveredItem)?.label} +
+ )} +
+
+ ) +} diff --git a/packages/graph-viewer/src/components/SelectionActions.tsx b/packages/graph-viewer/src/components/SelectionActions.tsx new file mode 100644 index 0000000..3a8f0e2 --- /dev/null +++ b/packages/graph-viewer/src/components/SelectionActions.tsx @@ -0,0 +1,294 @@ +/** + * SelectionActions - Bulk action buttons for selected nodes + * + * Actions: + * - Find common tags + * - Show connections between selected + * - Export selection (JSON/CSV) + * - Clear selection + * - Delete all (with confirmation) + */ + +import { useState, useMemo } from 'react' +import { + Tags, + GitBranch, + Download, + X, + Trash2, + AlertTriangle, +} from 'lucide-react' +import type { GraphNode, GraphEdge } from '../lib/types' + +interface SelectionActionsProps { + selectedNodes: GraphNode[] + allEdges: GraphEdge[] + onClearSelection: () => void + onHighlightConnections?: (nodeIds: string[]) => void + onExportSelection?: (nodes: GraphNode[], format: 'json' | 'csv') => void + onDeleteSelected?: (nodes: GraphNode[]) => void +} + +export function SelectionActions({ + selectedNodes, + allEdges, + onClearSelection, + onHighlightConnections, + onExportSelection, + onDeleteSelected, +}: SelectionActionsProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showCommonTags, setShowCommonTags] = useState(false) + + // Find common tags among selected nodes + const commonTags = useMemo(() => { + if (selectedNodes.length === 0) return [] + + // Get all tags from first node + const firstNodeTags = new Set(selectedNodes[0].tags) + + // Filter to only tags present in ALL selected nodes + const common: string[] = [] + firstNodeTags.forEach((tag) => { + if (selectedNodes.every((n) => n.tags.includes(tag))) { + common.push(tag) + } + }) + + return common.sort() + }, [selectedNodes]) + + // Count connections between selected nodes + const internalConnections = useMemo(() => { + const selectedIds = new Set(selectedNodes.map((n) => n.id)) + return allEdges.filter( + (e) => selectedIds.has(e.source) && selectedIds.has(e.target) + ).length + }, [selectedNodes, allEdges]) + + // Export selection as JSON + const handleExportJSON = () => { + if (onExportSelection) { + onExportSelection(selectedNodes, 'json') + } else { + const data = selectedNodes.map((n) => ({ + id: n.id, + content: n.content, + type: n.type, + tags: n.tags, + importance: n.importance, + timestamp: n.timestamp, + })) + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: 'application/json', + }) + downloadBlob(blob, `memory-selection-${Date.now()}.json`) + } + } + + // Export selection as CSV + const handleExportCSV = () => { + if (onExportSelection) { + onExportSelection(selectedNodes, 'csv') + } else { + const headers = ['id', 'content', 'type', 'tags', 'importance', 'timestamp'] + const rows = selectedNodes.map((n) => + [ + n.id, + `"${n.content.replace(/"/g, '""')}"`, + n.type, + `"${n.tags.join(', ')}"`, + n.importance, + n.timestamp, + ].join(',') + ) + const csv = [headers.join(','), ...rows].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + downloadBlob(blob, `memory-selection-${Date.now()}.csv`) + } + } + + // Handle delete with confirmation + const handleDelete = () => { + if (showDeleteConfirm) { + onDeleteSelected?.(selectedNodes) + setShowDeleteConfirm(false) + } else { + setShowDeleteConfirm(true) + } + } + + if (selectedNodes.length === 0) return null + + return ( +
+
+
+ {/* Common Tags Button */} + + + {/* Show Connections Button */} + + + {/* Export Dropdown */} +
+ +
+ + +
+
+ + {/* Divider */} +
+ + {/* Delete Button */} + {onDeleteSelected && ( + + )} + + {/* Clear Selection Button */} + +
+ + {/* Common Tags Panel */} + {showCommonTags && commonTags.length > 0 && ( +
+
Common tags:
+
+ {commonTags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {showCommonTags && commonTags.length === 0 && ( +
+
No common tags found
+
+ )} +
+ + {/* Cancel delete confirmation on click outside */} + {showDeleteConfirm && ( +
setShowDeleteConfirm(false)} + /> + )} +
+ ) +} + +// Helper to trigger download +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/packages/graph-viewer/src/components/TagCloud.tsx b/packages/graph-viewer/src/components/TagCloud.tsx new file mode 100644 index 0000000..afc541f --- /dev/null +++ b/packages/graph-viewer/src/components/TagCloud.tsx @@ -0,0 +1,254 @@ +/** + * TagCloud - Interactive floating tag cloud for filtering memories + * + * Features: + * - Tags sized by frequency + * - Colored by dominant memory type + * - Click to select/deselect tags + * - AND/OR filter mode toggle + * - Search to filter visible tags + * - Floating animation effect + */ + +import { useState, useEffect, useMemo, useRef } from 'react' +import { X, Search, ToggleLeft, ToggleRight, Trash2 } from 'lucide-react' +import type { TagData } from '../hooks/useTagCloud' + +interface TagCloudProps { + tags: TagData[] + filteredTags: TagData[] + selectedTags: Set + filterMode: 'AND' | 'OR' + filteredCount: number + totalCount: number + onToggleTag: (tag: string) => void + onClearSelection: () => void + onToggleFilterMode: () => void + onSearchChange: (term: string) => void + searchTerm: string + typeColors?: Record + visible: boolean + onClose: () => void +} + +// Default colors for memory types +const DEFAULT_TYPE_COLORS: Record = { + Decision: '#22c55e', + Pattern: '#8b5cf6', + Preference: '#f59e0b', + Style: '#ec4899', + Habit: '#06b6d4', + Insight: '#3b82f6', + Context: '#64748b', + Memory: '#6366f1', +} + +export function TagCloud({ + filteredTags, + selectedTags, + filterMode, + filteredCount, + totalCount, + onToggleTag, + onClearSelection, + onToggleFilterMode, + onSearchChange, + searchTerm, + typeColors = {}, + visible, + onClose, +}: TagCloudProps) { + const [isAnimating, setIsAnimating] = useState(false) + const containerRef = useRef(null) + + // Merge type colors with defaults + const colors = useMemo(() => ({ ...DEFAULT_TYPE_COLORS, ...typeColors }), [typeColors]) + + // Animate in/out + useEffect(() => { + if (visible) { + setIsAnimating(true) + } + }, [visible]) + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && visible) { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [visible, onClose]) + + if (!visible && !isAnimating) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
{ + if (!visible) setIsAnimating(false) + }} + > + {/* Header */} +
+
+

Tag Cloud

+ {selectedTags.size > 0 && ( + + {selectedTags.size} selected + + )} +
+
+ {/* Filter Mode Toggle */} + + + {/* Clear Selection */} + {selectedTags.size > 0 && ( + + )} + + {/* Close Button */} + +
+
+ + {/* Search */} +
+
+ + onSearchChange(e.target.value)} + placeholder="Search tags..." + className="w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition-colors" + /> +
+
+ + {/* Tags */} +
+ {filteredTags.length === 0 ? ( +
+ No tags found +
+ ) : ( +
+ {filteredTags.map((tagData, index) => { + const isSelected = selectedTags.has(tagData.tag) + const color = colors[tagData.dominantType] || colors.Memory + + // Calculate font size based on frequency (0.75rem to 1.5rem) + const fontSize = 0.75 + tagData.frequency * 0.75 + + // Slight animation delay for staggered appearance + const animationDelay = `${index * 20}ms` + + return ( + + ) + })} +
+ )} +
+ + {/* Footer - Results count */} +
+
+ + {filteredTags.length} tag{filteredTags.length !== 1 ? 's' : ''} shown + + {selectedTags.size > 0 && ( + + {filteredCount} of {totalCount} memories match + + )} +
+
+
+
+ ) +} diff --git a/packages/graph-viewer/src/components/TimelineBar.tsx b/packages/graph-viewer/src/components/TimelineBar.tsx new file mode 100644 index 0000000..882c466 --- /dev/null +++ b/packages/graph-viewer/src/components/TimelineBar.tsx @@ -0,0 +1,318 @@ +/** + * TimelineBar - Time travel UI component + * + * Shows a timeline scrubber at the bottom of the screen with: + * - Draggable playhead + * - Playback controls (play/pause, speed) + * - Date display + * - Memory count + */ + +import { useRef, useCallback, useState, useEffect } from 'react' + +interface TimelineBarProps { + isActive: boolean + isPlaying: boolean + currentTime: number + minTime: number + maxTime: number + progress: number + playbackSpeed: number + visibleCount: number + totalCount: number + onToggleActive: () => void + onTogglePlay: () => void + onSetProgress: (progress: number) => void + onStepForward: () => void + onStepBackward: () => void + onCycleSpeed: () => void + onGoToStart: () => void + onGoToEnd: () => void + visible?: boolean +} + +export function TimelineBar({ + isActive, + isPlaying, + currentTime, + minTime, + maxTime, + progress, + playbackSpeed, + visibleCount, + totalCount, + onToggleActive, + onTogglePlay, + onSetProgress, + onStepForward, + onStepBackward, + onCycleSpeed, + onGoToStart, + onGoToEnd, + visible = true, +}: TimelineBarProps) { + const trackRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + // Format date for display + const formatDate = (timestamp: number) => { + const date = new Date(timestamp) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } + + // Handle drag on timeline + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!isActive) return + + const track = trackRef.current + if (!track) return + + setIsDragging(true) + e.currentTarget.setPointerCapture(e.pointerId) + + const rect = track.getBoundingClientRect() + const x = e.clientX - rect.left + const newProgress = Math.max(0, Math.min(1, x / rect.width)) + onSetProgress(newProgress) + }, + [isActive, onSetProgress] + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isDragging || !isActive) return + + const track = trackRef.current + if (!track) return + + const rect = track.getBoundingClientRect() + const x = e.clientX - rect.left + const newProgress = Math.max(0, Math.min(1, x / rect.width)) + onSetProgress(newProgress) + }, + [isDragging, isActive, onSetProgress] + ) + + const handlePointerUp = useCallback(() => { + setIsDragging(false) + }, []) + + // Keyboard shortcuts when active + useEffect(() => { + if (!isActive) return + + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if focus is in an input + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return + } + + switch (e.key) { + case ' ': + e.preventDefault() + onTogglePlay() + break + case 'ArrowLeft': + if (e.shiftKey) { + onGoToStart() + } else { + onStepBackward() + } + break + case 'ArrowRight': + if (e.shiftKey) { + onGoToEnd() + } else { + onStepForward() + } + break + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isActive, onTogglePlay, onStepForward, onStepBackward, onGoToStart, onGoToEnd]) + + if (!visible) return null + + // Collapsed state when not active + if (!isActive) { + return ( +
+ +
+ ) + } + + return ( +
+
+ {/* Main timeline bar */} +
+ {/* Date and count display */} +
+
+ +
+ + + + Time Travel +
+
+ + {/* Current date - large and prominent */} +
+ + {formatDate(currentTime)} + +
+ + {/* Memory count */} +
+ {visibleCount} + / {totalCount} memories +
+
+ + {/* Timeline track */} +
+
+ {/* Progress fill */} +
+ + {/* Playhead */} +
+ + {/* Start/End labels */} +
+ {formatDate(minTime)} +
+
+ {formatDate(maxTime)} +
+
+
+ + {/* Playback controls */} +
+ {/* Go to start */} + + + {/* Step backward */} + + + {/* Play/Pause */} + + + {/* Step forward */} + + + {/* Go to end */} + + + {/* Speed control */} +
+ +
+
+
+
+
+ ) +} diff --git a/packages/graph-viewer/src/components/settings/SettingsPanel.tsx b/packages/graph-viewer/src/components/settings/SettingsPanel.tsx index 138184e..cc75cb7 100644 --- a/packages/graph-viewer/src/components/settings/SettingsPanel.tsx +++ b/packages/graph-viewer/src/components/settings/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { X, RotateCcw, Zap } from 'lucide-react' +import { X, RotateCcw, Zap, Volume2 } from 'lucide-react' import { SettingsSection } from './SettingsSection' import { SliderControl } from './SliderControl' import { ToggleControl } from './ToggleControl' @@ -61,6 +61,11 @@ interface SettingsPanelProps { // Relationship visibility relationshipVisibility: RelationshipVisibility onRelationshipVisibilityChange: (visibility: Partial) => void + // Audio settings + soundEnabled: boolean + onSoundEnabledChange: (enabled: boolean) => void + soundVolume: number + onSoundVolumeChange: (volume: number) => void } export function SettingsPanel({ @@ -79,6 +84,10 @@ export function SettingsPanel({ onClusterConfigChange, relationshipVisibility, onRelationshipVisibilityChange, + soundEnabled, + onSoundEnabledChange, + soundVolume, + onSoundVolumeChange, }: SettingsPanelProps) { if (!isOpen) return null @@ -399,6 +408,39 @@ export function SettingsPanel({
+ + {/* Audio Section */} + +
+ + + {soundEnabled && ( +
+
+ + `${Math.round(v * 100)}%`} + /> +
+ +

+ Sounds include: node select, hover, zoom, search typing, bookmarks, and more. +

+
+ )} +
+
) diff --git a/packages/graph-viewer/src/hooks/useBookmarks.ts b/packages/graph-viewer/src/hooks/useBookmarks.ts new file mode 100644 index 0000000..15700ba --- /dev/null +++ b/packages/graph-viewer/src/hooks/useBookmarks.ts @@ -0,0 +1,127 @@ +/** + * useBookmarks - Save and restore camera positions + * + * Persists bookmarks to localStorage for cross-session access. + * Each bookmark captures camera position, zoom, and optionally selected node. + */ + +import { useState, useCallback, useEffect } from 'react' + +export interface Bookmark { + id: string + name: string + position: { x: number; y: number; z: number } + zoom: number + selectedNodeId?: string + createdAt: string + thumbnail?: string // Base64 encoded thumbnail (optional) +} + +interface UseBookmarksOptions { + storageKey?: string + maxBookmarks?: number +} + +const DEFAULT_STORAGE_KEY = 'graph-viewer-bookmarks' +const DEFAULT_MAX_BOOKMARKS = 20 + +export function useBookmarks({ + storageKey = DEFAULT_STORAGE_KEY, + maxBookmarks = DEFAULT_MAX_BOOKMARKS, +}: UseBookmarksOptions = {}) { + const [bookmarks, setBookmarks] = useState([]) + + // Load bookmarks from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(storageKey) + if (stored) { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed)) { + setBookmarks(parsed) + } + } + } catch (e) { + console.warn('Failed to load bookmarks:', e) + } + }, [storageKey]) + + // Save bookmarks to localStorage whenever they change + useEffect(() => { + try { + localStorage.setItem(storageKey, JSON.stringify(bookmarks)) + } catch (e) { + console.warn('Failed to save bookmarks:', e) + } + }, [bookmarks, storageKey]) + + // Add a new bookmark + const addBookmark = useCallback(( + position: { x: number; y: number; z: number }, + zoom: number, + selectedNodeId?: string, + name?: string, + thumbnail?: string + ) => { + const newBookmark: Bookmark = { + id: `bookmark-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + name: name || `Bookmark ${bookmarks.length + 1}`, + position, + zoom, + selectedNodeId, + createdAt: new Date().toISOString(), + thumbnail, + } + + setBookmarks(prev => { + const updated = [newBookmark, ...prev] + // Limit to max bookmarks + return updated.slice(0, maxBookmarks) + }) + + return newBookmark + }, [bookmarks.length, maxBookmarks]) + + // Update an existing bookmark + const updateBookmark = useCallback((id: string, updates: Partial>) => { + setBookmarks(prev => + prev.map(b => b.id === id ? { ...b, ...updates } : b) + ) + }, []) + + // Delete a bookmark + const deleteBookmark = useCallback((id: string) => { + setBookmarks(prev => prev.filter(b => b.id !== id)) + }, []) + + // Get bookmark by index (1-9 for quick access) + const getBookmarkByIndex = useCallback((index: number): Bookmark | undefined => { + // index is 1-based (keyboard shortcuts 1-9) + return bookmarks[index - 1] + }, [bookmarks]) + + // Clear all bookmarks + const clearAllBookmarks = useCallback(() => { + setBookmarks([]) + }, []) + + // Reorder bookmarks (for drag-and-drop) + const reorderBookmarks = useCallback((fromIndex: number, toIndex: number) => { + setBookmarks(prev => { + const updated = [...prev] + const [moved] = updated.splice(fromIndex, 1) + updated.splice(toIndex, 0, moved) + return updated + }) + }, []) + + return { + bookmarks, + addBookmark, + updateBookmark, + deleteBookmark, + getBookmarkByIndex, + clearAllBookmarks, + reorderBookmarks, + } +} diff --git a/packages/graph-viewer/src/hooks/useCameraState.ts b/packages/graph-viewer/src/hooks/useCameraState.ts new file mode 100644 index 0000000..febfc28 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useCameraState.ts @@ -0,0 +1,106 @@ +/** + * useCameraState - Track and control camera position + * + * Provides camera position for mini-map and navigation + */ + +import { useRef, useCallback } from 'react' +import { useFrame, useThree } from '@react-three/fiber' +import * as THREE from 'three' + +export interface CameraState { + position: { x: number; y: number; z: number } + zoom: number + target: { x: number; y: number; z: number } +} + +interface UseCameraStateOptions { + onCameraChange?: (state: CameraState) => void + updateInterval?: number // ms between updates +} + +export function useCameraState({ onCameraChange, updateInterval = 100 }: UseCameraStateOptions = {}) { + const { camera } = useThree() + const lastUpdateRef = useRef(0) + const lastStateRef = useRef({ + position: { x: 0, y: 0, z: 100 }, + zoom: 1, + target: { x: 0, y: 0, z: 0 }, + }) + + useFrame(() => { + if (!onCameraChange) return + + const now = performance.now() + if (now - lastUpdateRef.current < updateInterval) return + lastUpdateRef.current = now + + const pos = camera.position + const state: CameraState = { + position: { x: pos.x, y: pos.y, z: pos.z }, + zoom: camera instanceof THREE.PerspectiveCamera + ? 100 / pos.distanceTo(new THREE.Vector3(0, 0, 0)) + : 1, + target: { x: 0, y: 0, z: 0 }, // OrbitControls target would go here + } + + // Only update if position changed significantly + const lastPos = lastStateRef.current.position + const dist = Math.sqrt( + Math.pow(pos.x - lastPos.x, 2) + + Math.pow(pos.y - lastPos.y, 2) + + Math.pow(pos.z - lastPos.z, 2) + ) + + if (dist > 0.5 || Math.abs(state.zoom - lastStateRef.current.zoom) > 0.01) { + lastStateRef.current = state + onCameraChange(state) + } + }) + + return lastStateRef.current +} + +/** + * Camera navigation helper - smoothly animate to a position + */ +export function useCameraNavigation() { + const { camera, controls } = useThree() + const animationRef = useRef(null) + + const navigateTo = useCallback((targetX: number, targetY: number, duration = 500) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + + const startPos = camera.position.clone() + const targetPos = new THREE.Vector3(targetX, targetY, startPos.z) + const startTime = performance.now() + + const animate = () => { + const elapsed = performance.now() - startTime + const progress = Math.min(elapsed / duration, 1) + + // Ease out cubic + const eased = 1 - Math.pow(1 - progress, 3) + + camera.position.lerpVectors(startPos, targetPos, eased) + + // Update OrbitControls target if available + if (controls && 'target' in controls) { + const orbitControls = controls as { target: THREE.Vector3 } + orbitControls.target.set(targetX, targetY, 0) + } + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate) + } else { + animationRef.current = null + } + } + + animationRef.current = requestAnimationFrame(animate) + }, [camera, controls]) + + return { navigateTo } +} diff --git a/packages/graph-viewer/src/hooks/useFocusMode.ts b/packages/graph-viewer/src/hooks/useFocusMode.ts new file mode 100644 index 0000000..837e240 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useFocusMode.ts @@ -0,0 +1,165 @@ +/** + * useFocusMode - Calculate depth-based opacity for spotlight effect + * + * When focus mode is active, nodes are dimmed based on their + * graph distance from the selected node: + * - Selected node: 100% opacity + * - 1st degree neighbors: 100% opacity + * - 2nd degree neighbors: 60% opacity + * - 3rd degree neighbors: 30% opacity + * - Beyond: 10% opacity + */ + +import { useMemo } from 'react' +import type { GraphNode, GraphEdge } from '../lib/types' + +export interface FocusModeConfig { + enabled: boolean + selectedNodeId: string | null + transitionProgress: number // 0-1, for smooth fade in/out +} + +export interface NodeFocusState { + depth: number // -1 if no selection, 0 for selected, 1+ for neighbors + opacity: number // Computed opacity based on depth + isInFocus: boolean // True if depth <= 3 +} + +// Opacity values for each depth level +const DEPTH_OPACITY = [ + 1.0, // depth 0 (selected) + 1.0, // depth 1 (direct neighbors) + 0.6, // depth 2 + 0.3, // depth 3 +] +const DEFAULT_OPACITY = 0.08 // Beyond depth 3 + +/** + * Build adjacency map from edges + */ +function buildAdjacencyMap(edges: GraphEdge[]): Map> { + const adjacency = new Map>() + + edges.forEach(edge => { + if (!adjacency.has(edge.source)) { + adjacency.set(edge.source, new Set()) + } + if (!adjacency.has(edge.target)) { + adjacency.set(edge.target, new Set()) + } + adjacency.get(edge.source)!.add(edge.target) + adjacency.get(edge.target)!.add(edge.source) + }) + + return adjacency +} + +/** + * BFS to compute depth from source node + */ +function computeDepths( + sourceId: string, + adjacency: Map>, + maxDepth: number = 3 +): Map { + const depths = new Map() + depths.set(sourceId, 0) + + const queue: { id: string; depth: number }[] = [{ id: sourceId, depth: 0 }] + + while (queue.length > 0) { + const { id, depth } = queue.shift()! + + if (depth >= maxDepth) continue + + const neighbors = adjacency.get(id) + if (!neighbors) continue + + for (const neighborId of neighbors) { + if (!depths.has(neighborId)) { + depths.set(neighborId, depth + 1) + queue.push({ id: neighborId, depth: depth + 1 }) + } + } + } + + return depths +} + +/** + * Hook to compute focus mode state for all nodes + */ +export function useFocusMode( + nodes: GraphNode[], + edges: GraphEdge[], + selectedNodeId: string | null, + enabled: boolean, + transitionProgress: number = 1 +): Map { + return useMemo(() => { + const result = new Map() + + // If not enabled or no selection, all nodes are fully visible + if (!enabled || !selectedNodeId) { + nodes.forEach(node => { + result.set(node.id, { + depth: -1, + opacity: 1.0, + isInFocus: true, + }) + }) + return result + } + + // Build adjacency and compute depths + const adjacency = buildAdjacencyMap(edges) + const depths = computeDepths(selectedNodeId, adjacency, 3) + + // Compute opacity for each node + nodes.forEach(node => { + const depth = depths.get(node.id) ?? Infinity + const isInFocus = depth <= 3 + + // Get target opacity based on depth + let targetOpacity: number + if (depth < DEPTH_OPACITY.length) { + targetOpacity = DEPTH_OPACITY[depth] + } else { + targetOpacity = DEFAULT_OPACITY + } + + // Interpolate with transition progress (for smooth fade) + // When transitioning IN: go from 1.0 to target + // When transitioning OUT: go from target to 1.0 + const opacity = 1.0 + (targetOpacity - 1.0) * transitionProgress + + result.set(node.id, { + depth: depth === Infinity ? -1 : depth, + opacity, + isInFocus, + }) + }) + + return result + }, [nodes, edges, selectedNodeId, enabled, transitionProgress]) +} + +/** + * Get opacity for a specific node + */ +export function getNodeFocusOpacity( + focusStates: Map, + nodeId: string +): number { + return focusStates.get(nodeId)?.opacity ?? 1.0 +} + +/** + * Check if node is in focus (within 3 degrees of selected) + */ +export function isNodeInFocus( + focusStates: Map, + nodeId: string +): boolean { + return focusStates.get(nodeId)?.isInFocus ?? true +} diff --git a/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts index fc55c34..83350f7 100644 --- a/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts +++ b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts @@ -9,6 +9,12 @@ interface UseKeyboardNavigationOptions { onResetView?: () => void onToggleSettings?: () => void onToggleLabels?: () => void + onToggleFocus?: () => void + onSaveBookmark?: () => void + onQuickNavigate?: (index: number) => void + onStartPathfinding?: () => void + onCancelPathfinding?: () => void + isPathSelecting?: boolean enabled?: boolean } @@ -31,6 +37,12 @@ export function useKeyboardNavigation({ onResetView, onToggleSettings, onToggleLabels, + onToggleFocus, + onSaveBookmark, + onQuickNavigate, + onStartPathfinding, + onCancelPathfinding, + isPathSelecting, enabled = true, }: UseKeyboardNavigationOptions) { const nodesRef = useRef(nodes) @@ -140,6 +152,22 @@ export function useKeyboardNavigation({ return } + // Handle Cmd/Ctrl+B for save bookmark + if ((event.metaKey || event.ctrlKey) && event.key === 'b') { + event.preventDefault() + onSaveBookmark?.() + return + } + + // Handle number keys 1-9 for quick bookmark navigation + if (!event.metaKey && !event.ctrlKey && !event.altKey) { + const num = parseInt(event.key) + if (num >= 1 && num <= 9) { + onQuickNavigate?.(num) + return + } + } + const shortcuts: KeyboardShortcuts = { // Navigation ArrowUp: { @@ -187,12 +215,27 @@ export function useKeyboardNavigation({ // Selection Escape: { - description: 'Deselect', + description: 'Deselect / Cancel pathfinding', action: () => { + // If pathfinding is in progress, cancel it first + if (isPathSelecting) { + onCancelPathfinding?.() + return + } onNodeSelect(null) }, }, + // Pathfinding + p: { + description: 'Start pathfinding from selected node', + action: () => { + if (!event.metaKey && !event.ctrlKey && selectedRef.current) { + onStartPathfinding?.() + } + }, + }, + // Actions r: { description: 'Reheat simulation', @@ -224,6 +267,14 @@ export function useKeyboardNavigation({ } }, }, + f: { + description: 'Toggle focus mode', + action: () => { + if (!event.metaKey && !event.ctrlKey) { + onToggleFocus?.() + } + }, + }, // Help '?': { @@ -234,11 +285,15 @@ export function useKeyboardNavigation({ console.log(' Arrow keys: Navigate between nodes') console.log(' Shift+Arrow Up/Down: Navigate in Z axis') console.log(' Tab/Shift+Tab: Cycle through nodes') - console.log(' Escape: Deselect') + console.log(' Escape: Deselect / Cancel pathfinding') + console.log(' P: Find path from selected node') console.log(' R: Reheat simulation') console.log(' Shift+R: Reset view') console.log(' ,: Toggle settings') console.log(' L: Toggle labels') + console.log(' F: Toggle focus mode') + console.log(' Cmd+B: Save bookmark') + console.log(' 1-9: Quick navigate to bookmark') }, }, } @@ -248,7 +303,7 @@ export function useKeyboardNavigation({ shortcut.action() } }, - [enabled, findNodeInDirection, navigateSequential, onNodeSelect, onReheat, onResetView, onToggleSettings, onToggleLabels] + [enabled, findNodeInDirection, navigateSequential, onNodeSelect, onReheat, onResetView, onToggleSettings, onToggleLabels, onToggleFocus, onSaveBookmark, onQuickNavigate, onStartPathfinding, onCancelPathfinding, isPathSelecting] ) // Attach event listener @@ -268,11 +323,15 @@ export function useKeyboardNavigation({ { key: 'Shift+↑↓', description: 'Navigate in Z axis' }, { key: 'Tab', description: 'Next node' }, { key: 'Shift+Tab', description: 'Previous node' }, - { key: 'Esc', description: 'Deselect' }, + { key: 'Esc', description: 'Deselect / Cancel pathfinding' }, + { key: 'P', description: 'Find path from selected node' }, { key: 'R', description: 'Reheat simulation' }, { key: 'Shift+R', description: 'Reset view' }, { key: ',', description: 'Toggle settings' }, { key: 'L', description: 'Toggle labels' }, + { key: 'F', description: 'Toggle focus mode' }, + { key: 'Cmd+B', description: 'Save bookmark' }, + { key: '1-9', description: 'Quick navigate to bookmark' }, { key: '?', description: 'Show help' }, ], } diff --git a/packages/graph-viewer/src/hooks/useLassoSelection.ts b/packages/graph-viewer/src/hooks/useLassoSelection.ts new file mode 100644 index 0000000..3e5d20b --- /dev/null +++ b/packages/graph-viewer/src/hooks/useLassoSelection.ts @@ -0,0 +1,248 @@ +/** + * useLassoSelection - Select multiple nodes by drawing around them + * + * Features: + * - Hold Shift + Drag to draw a lasso + * - Point-in-polygon check for nodes + * - Projects 3D positions to 2D screen space + */ + +import { useState, useCallback, useRef, useMemo } from 'react' +import type { SimulationNode, GraphNode } from '../lib/types' +import * as THREE from 'three' + +interface LassoPoint { + x: number + y: number +} + +export interface LassoSelectionState { + isDrawing: boolean + points: LassoPoint[] + selectedIds: Set +} + +interface UseLassoSelectionOptions { + nodes: SimulationNode[] + camera: THREE.Camera | null + canvasRect: DOMRect | null + enabled?: boolean + onSelectionChange?: (selectedNodes: GraphNode[]) => void +} + +interface UseLassoSelectionReturn { + // State + state: LassoSelectionState + isDrawing: boolean + points: LassoPoint[] + selectedIds: Set + selectedNodes: GraphNode[] + + // Actions + startDrawing: (x: number, y: number) => void + addPoint: (x: number, y: number) => void + finishDrawing: () => void + cancelDrawing: () => void + clearSelection: () => void + toggleNodeSelection: (nodeId: string) => void +} + +// Point-in-polygon check using ray casting algorithm +function isPointInPolygon(point: LassoPoint, polygon: LassoPoint[]): boolean { + if (polygon.length < 3) return false + + let inside = false + const n = polygon.length + + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = polygon[i].x + const yi = polygon[i].y + const xj = polygon[j].x + const yj = polygon[j].y + + if (yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi) { + inside = !inside + } + } + + return inside +} + +// Project 3D position to 2D screen coordinates +function projectToScreen( + position: THREE.Vector3, + camera: THREE.Camera, + width: number, + height: number +): LassoPoint { + const projected = position.clone().project(camera) + + return { + x: ((projected.x + 1) / 2) * width, + y: ((-projected.y + 1) / 2) * height, + } +} + +export function useLassoSelection({ + nodes, + camera, + canvasRect, + enabled = true, + onSelectionChange, +}: UseLassoSelectionOptions): UseLassoSelectionReturn { + const [state, setState] = useState({ + isDrawing: false, + points: [], + selectedIds: new Set(), + }) + + // Track the last added point to avoid duplicates + const lastPointRef = useRef(null) + + // Start drawing the lasso + const startDrawing = useCallback( + (x: number, y: number) => { + if (!enabled) return + + const point = { x, y } + lastPointRef.current = point + setState((prev) => ({ + ...prev, + isDrawing: true, + points: [point], + })) + }, + [enabled] + ) + + // Add a point to the lasso path + const addPoint = useCallback( + (x: number, y: number) => { + if (!enabled) return + + const point = { x, y } + const last = lastPointRef.current + + // Only add if moved enough (avoid too many points) + if (last) { + const dist = Math.sqrt(Math.pow(x - last.x, 2) + Math.pow(y - last.y, 2)) + if (dist < 3) return + } + + lastPointRef.current = point + setState((prev) => { + if (!prev.isDrawing) return prev + return { + ...prev, + points: [...prev.points, point], + } + }) + }, + [enabled] + ) + + // Finish drawing and select nodes inside the polygon + const finishDrawing = useCallback(() => { + if (!enabled || !camera || !canvasRect) { + setState((prev) => ({ ...prev, isDrawing: false, points: [] })) + return + } + + setState((prev) => { + if (!prev.isDrawing || prev.points.length < 3) { + return { ...prev, isDrawing: false, points: [] } + } + + // Find all nodes inside the lasso polygon + const newSelectedIds = new Set(prev.selectedIds) + + nodes.forEach((node) => { + const worldPos = new THREE.Vector3(node.x ?? 0, node.y ?? 0, node.z ?? 0) + const screenPos = projectToScreen(worldPos, camera, canvasRect.width, canvasRect.height) + + if (isPointInPolygon(screenPos, prev.points)) { + newSelectedIds.add(node.id) + } + }) + + return { + isDrawing: false, + points: [], + selectedIds: newSelectedIds, + } + }) + + lastPointRef.current = null + }, [enabled, camera, canvasRect, nodes]) + + // Cancel drawing without selecting + const cancelDrawing = useCallback(() => { + lastPointRef.current = null + setState((prev) => ({ + ...prev, + isDrawing: false, + points: [], + })) + }, []) + + // Clear all selected nodes + const clearSelection = useCallback(() => { + setState((prev) => ({ + ...prev, + selectedIds: new Set(), + })) + onSelectionChange?.([]) + }, [onSelectionChange]) + + // Toggle a single node's selection + const toggleNodeSelection = useCallback((nodeId: string) => { + setState((prev) => { + const newIds = new Set(prev.selectedIds) + if (newIds.has(nodeId)) { + newIds.delete(nodeId) + } else { + newIds.add(nodeId) + } + return { ...prev, selectedIds: newIds } + }) + }, []) + + // Get selected nodes as full GraphNode objects + const selectedNodes = useMemo(() => { + return nodes.filter((n) => state.selectedIds.has(n.id)) as GraphNode[] + }, [nodes, state.selectedIds]) + + // Notify parent when selection changes + const prevSelectionRef = useRef>(new Set()) + if ( + onSelectionChange && + !state.isDrawing && + !areSetsEqual(prevSelectionRef.current, state.selectedIds) + ) { + prevSelectionRef.current = new Set(state.selectedIds) + onSelectionChange(selectedNodes) + } + + return { + state, + isDrawing: state.isDrawing, + points: state.points, + selectedIds: state.selectedIds, + selectedNodes, + startDrawing, + addPoint, + finishDrawing, + cancelDrawing, + clearSelection, + toggleNodeSelection, + } +} + +// Helper to compare sets +function areSetsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) return false + for (const item of a) { + if (!b.has(item)) return false + } + return true +} diff --git a/packages/graph-viewer/src/hooks/usePathfinding.ts b/packages/graph-viewer/src/hooks/usePathfinding.ts new file mode 100644 index 0000000..891d48c --- /dev/null +++ b/packages/graph-viewer/src/hooks/usePathfinding.ts @@ -0,0 +1,347 @@ +/** + * usePathfinding - Find shortest paths between memory nodes + * + * Implements Dijkstra's algorithm with edge weights based on relationship strength. + * Stronger relationships = lower weight = preferred path. + */ + +import { useMemo, useCallback, useState } from 'react' +import type { GraphEdge, SimulationNode } from '../lib/types' + +export interface PathStep { + nodeId: string + edgeType?: string + strength?: number +} + +export interface PathResult { + path: PathStep[] + totalStrength: number + hopCount: number +} + +interface UsePathfindingOptions { + nodes: SimulationNode[] + edges: GraphEdge[] +} + +interface PathfindingState { + sourceId: string | null + targetId: string | null + isSelectingTarget: boolean + paths: PathResult[] + activePath: number // Index of currently displayed path +} + +/** + * Build adjacency list from edges + */ +function buildAdjacency(edges: GraphEdge[]): Map { + const adjacency = new Map() + + edges.forEach(edge => { + // Weight is inverse of strength (stronger = lower weight = preferred) + const weight = 1 - (edge.strength ?? 0.5) + + // Add both directions (undirected graph) + if (!adjacency.has(edge.source)) { + adjacency.set(edge.source, []) + } + adjacency.get(edge.source)!.push({ + nodeId: edge.target, + weight, + type: edge.type, + strength: edge.strength ?? 0.5, + }) + + if (!adjacency.has(edge.target)) { + adjacency.set(edge.target, []) + } + adjacency.get(edge.target)!.push({ + nodeId: edge.source, + weight, + type: edge.type, + strength: edge.strength ?? 0.5, + }) + }) + + return adjacency +} + +/** + * Dijkstra's algorithm to find shortest path + */ +function dijkstra( + adjacency: Map, + source: string, + target: string +): PathResult | null { + const distances = new Map() + const previous = new Map() + const visited = new Set() + + // Priority queue (simple implementation - for large graphs, use a proper heap) + const queue: { nodeId: string; distance: number }[] = [] + + // Initialize + distances.set(source, 0) + previous.set(source, null) + queue.push({ nodeId: source, distance: 0 }) + + while (queue.length > 0) { + // Get node with smallest distance + queue.sort((a, b) => a.distance - b.distance) + const current = queue.shift()! + + if (visited.has(current.nodeId)) continue + visited.add(current.nodeId) + + // Found target + if (current.nodeId === target) { + break + } + + // Process neighbors + const neighbors = adjacency.get(current.nodeId) || [] + for (const neighbor of neighbors) { + if (visited.has(neighbor.nodeId)) continue + + const newDist = (distances.get(current.nodeId) ?? Infinity) + neighbor.weight + + if (newDist < (distances.get(neighbor.nodeId) ?? Infinity)) { + distances.set(neighbor.nodeId, newDist) + previous.set(neighbor.nodeId, { + nodeId: current.nodeId, + edgeType: neighbor.type, + strength: neighbor.strength, + }) + queue.push({ nodeId: neighbor.nodeId, distance: newDist }) + } + } + } + + // Check if path exists + if (!previous.has(target)) { + return null + } + + // Reconstruct path + const path: PathStep[] = [] + let current: string | null = target + let totalStrength = 0 + let hopCount = 0 + + while (current) { + const prev = previous.get(current) + path.unshift({ + nodeId: current, + edgeType: prev?.edgeType, + strength: prev?.strength, + }) + + if (prev?.strength) { + totalStrength += prev.strength + hopCount++ + } + + current = prev?.nodeId ?? null + } + + return { + path, + totalStrength: hopCount > 0 ? totalStrength / hopCount : 0, + hopCount, + } +} + +/** + * Find alternative paths by temporarily removing edges from the primary path + */ +function findAlternativePaths( + edges: GraphEdge[], + source: string, + target: string, + primaryPath: PathResult, + maxAlternatives: number = 2 +): PathResult[] { + const alternatives: PathResult[] = [] + const primaryEdges = new Set() + + // Identify edges in primary path + for (let i = 0; i < primaryPath.path.length - 1; i++) { + const from = primaryPath.path[i].nodeId + const to = primaryPath.path[i + 1].nodeId + primaryEdges.add(`${from}-${to}`) + primaryEdges.add(`${to}-${from}`) + } + + // Try removing each edge and finding alternative + for (let i = 0; i < primaryPath.path.length - 1 && alternatives.length < maxAlternatives; i++) { + const from = primaryPath.path[i].nodeId + const to = primaryPath.path[i + 1].nodeId + + // Filter out this edge + const filteredEdges = edges.filter(e => { + const edgeKey1 = `${e.source}-${e.target}` + const edgeKey2 = `${e.target}-${e.source}` + return edgeKey1 !== `${from}-${to}` && edgeKey2 !== `${from}-${to}` && + edgeKey1 !== `${to}-${from}` && edgeKey2 !== `${to}-${from}` + }) + + const altAdjacency = buildAdjacency(filteredEdges) + const altPath = dijkstra(altAdjacency, source, target) + + if (altPath && !pathsEqual(altPath.path, primaryPath.path)) { + // Check if we already have this alternative + const isDuplicate = alternatives.some(a => pathsEqual(a.path, altPath.path)) + if (!isDuplicate) { + alternatives.push(altPath) + } + } + } + + return alternatives +} + +function pathsEqual(path1: PathStep[], path2: PathStep[]): boolean { + if (path1.length !== path2.length) return false + return path1.every((step, i) => step.nodeId === path2[i].nodeId) +} + +export function usePathfinding({ nodes: _nodes, edges }: UsePathfindingOptions) { + const [state, setState] = useState({ + sourceId: null, + targetId: null, + isSelectingTarget: false, + paths: [], + activePath: 0, + }) + + // Build adjacency list (memoized) + const adjacency = useMemo(() => buildAdjacency(edges), [edges]) + + // Start path selection from a node + const startPathSelection = useCallback((nodeId: string) => { + setState(prev => ({ + ...prev, + sourceId: nodeId, + targetId: null, + isSelectingTarget: true, + paths: [], + activePath: 0, + })) + }, []) + + // Complete path selection to a target node + const completePathSelection = useCallback((targetId: string) => { + if (!state.sourceId || state.sourceId === targetId) return + + const primaryPath = dijkstra(adjacency, state.sourceId, targetId) + + if (!primaryPath) { + // No path found + setState(prev => ({ + ...prev, + targetId, + isSelectingTarget: false, + paths: [], + })) + return + } + + // Find alternative paths + const alternatives = findAlternativePaths(edges, state.sourceId, targetId, primaryPath) + + setState(prev => ({ + ...prev, + targetId, + isSelectingTarget: false, + paths: [primaryPath, ...alternatives], + activePath: 0, + })) + }, [state.sourceId, adjacency, edges]) + + // Cancel path selection + const cancelPathSelection = useCallback(() => { + setState({ + sourceId: null, + targetId: null, + isSelectingTarget: false, + paths: [], + activePath: 0, + }) + }, []) + + // Clear current path (but keep source selected) + const clearPath = useCallback(() => { + setState(prev => ({ + ...prev, + targetId: null, + isSelectingTarget: false, + paths: [], + activePath: 0, + })) + }, []) + + // Cycle through alternative paths + const nextPath = useCallback(() => { + setState(prev => ({ + ...prev, + activePath: (prev.activePath + 1) % Math.max(1, prev.paths.length), + })) + }, []) + + const previousPath = useCallback(() => { + setState(prev => ({ + ...prev, + activePath: (prev.activePath - 1 + prev.paths.length) % Math.max(1, prev.paths.length), + })) + }, []) + + // Get node IDs in the current active path + const pathNodeIds = useMemo(() => { + if (state.paths.length === 0) return new Set() + const currentPath = state.paths[state.activePath] + return new Set(currentPath?.path.map(step => step.nodeId) ?? []) + }, [state.paths, state.activePath]) + + // Get edge keys in the current active path (for highlighting) + const pathEdgeKeys = useMemo(() => { + if (state.paths.length === 0) return new Set() + const currentPath = state.paths[state.activePath] + if (!currentPath) return new Set() + + const keys = new Set() + for (let i = 0; i < currentPath.path.length - 1; i++) { + const from = currentPath.path[i].nodeId + const to = currentPath.path[i + 1].nodeId + keys.add(`${from}-${to}`) + keys.add(`${to}-${from}`) + } + return keys + }, [state.paths, state.activePath]) + + return { + // State + sourceId: state.sourceId, + targetId: state.targetId, + isSelectingTarget: state.isSelectingTarget, + paths: state.paths, + activePath: state.activePath, + currentPath: state.paths[state.activePath] ?? null, + pathNodeIds, + pathEdgeKeys, + + // Actions + startPathSelection, + completePathSelection, + cancelPathSelection, + clearPath, + nextPath, + previousPath, + + // Computed + hasPath: state.paths.length > 0, + pathCount: state.paths.length, + } +} diff --git a/packages/graph-viewer/src/hooks/useSoundEffects.ts b/packages/graph-viewer/src/hooks/useSoundEffects.ts new file mode 100644 index 0000000..8cc8cbb --- /dev/null +++ b/packages/graph-viewer/src/hooks/useSoundEffects.ts @@ -0,0 +1,122 @@ +/** + * useSoundEffects - Hook for sound effects throughout the app + * + * Provides easy access to sound manager and reactive settings state. + */ + +import { useState, useCallback, useEffect } from 'react' +import { soundManager, type SoundSettings, type SoundType } from '../lib/sounds' + +export interface UseSoundEffectsReturn { + // Settings + settings: SoundSettings + setMasterVolume: (volume: number) => void + setEnabled: (enabled: boolean) => void + toggleSound: (type: SoundType, enabled: boolean) => void + + // Play sounds + playSelect: (importance?: number) => void + playHover: () => void + playZoomIn: () => void + playZoomOut: () => void + playSearch: () => void + playBookmark: () => void + playDelete: () => void + playError: () => void + playSuccess: () => void + playPathFound: () => void + playTimeTravel: () => void + playLasso: () => void +} + +export function useSoundEffects(): UseSoundEffectsReturn { + const [settings, setSettings] = useState(() => soundManager.getSettings()) + + // Sync settings from localStorage on mount + useEffect(() => { + setSettings(soundManager.getSettings()) + }, []) + + const setMasterVolume = useCallback((volume: number) => { + soundManager.setMasterVolume(volume) + setSettings(soundManager.getSettings()) + }, []) + + const setEnabled = useCallback((enabled: boolean) => { + soundManager.setEnabled(enabled) + setSettings(soundManager.getSettings()) + }, []) + + const toggleSound = useCallback((type: SoundType, enabled: boolean) => { + soundManager.toggleSound(type, enabled) + setSettings(soundManager.getSettings()) + }, []) + + // Sound playback functions + const playSelect = useCallback((importance?: number) => { + soundManager.playSelect(importance) + }, []) + + const playHover = useCallback(() => { + soundManager.playHover() + }, []) + + const playZoomIn = useCallback(() => { + soundManager.playZoom(true) + }, []) + + const playZoomOut = useCallback(() => { + soundManager.playZoom(false) + }, []) + + const playSearch = useCallback(() => { + soundManager.playSearch() + }, []) + + const playBookmark = useCallback(() => { + soundManager.playBookmark() + }, []) + + const playDelete = useCallback(() => { + soundManager.playDelete() + }, []) + + const playError = useCallback(() => { + soundManager.playError() + }, []) + + const playSuccess = useCallback(() => { + soundManager.playSuccess() + }, []) + + const playPathFound = useCallback(() => { + soundManager.playPathFound() + }, []) + + const playTimeTravel = useCallback(() => { + soundManager.playTimeTravel() + }, []) + + const playLasso = useCallback(() => { + soundManager.playLasso() + }, []) + + return { + settings, + setMasterVolume, + setEnabled, + toggleSound, + playSelect, + playHover, + playZoomIn, + playZoomOut, + playSearch, + playBookmark, + playDelete, + playError, + playSuccess, + playPathFound, + playTimeTravel, + playLasso, + } +} diff --git a/packages/graph-viewer/src/hooks/useTagCloud.ts b/packages/graph-viewer/src/hooks/useTagCloud.ts new file mode 100644 index 0000000..0aa7246 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useTagCloud.ts @@ -0,0 +1,182 @@ +/** + * useTagCloud - Aggregate and manage tags for the interactive tag cloud + * + * Features: + * - Aggregates tags from all nodes + * - Calculates tag frequency and dominant type + * - Supports multi-select filtering (AND/OR modes) + */ + +import { useMemo, useState, useCallback } from 'react' +import type { GraphNode } from '../lib/types' + +export interface TagData { + tag: string + count: number + frequency: number // 0-1 based on max count + dominantType: string + types: Record // Type -> count mapping +} + +export interface UseTagCloudOptions { + nodes: GraphNode[] + typeColors?: Record + maxTags?: number +} + +export interface UseTagCloudReturn { + // Data + tags: TagData[] + filteredTags: TagData[] + + // Selection state + selectedTags: Set + filterMode: 'AND' | 'OR' + + // Filtered nodes based on selection + filteredNodeIds: Set + hasActiveFilter: boolean + + // Actions + toggleTag: (tag: string) => void + selectTag: (tag: string) => void + deselectTag: (tag: string) => void + clearSelection: () => void + toggleFilterMode: () => void + setFilterMode: (mode: 'AND' | 'OR') => void + + // Search + searchTerm: string + setSearchTerm: (term: string) => void +} + +export function useTagCloud({ + nodes, + typeColors: _typeColors = {}, + maxTags = 50, +}: UseTagCloudOptions): UseTagCloudReturn { + const [selectedTags, setSelectedTags] = useState>(new Set()) + const [filterMode, setFilterMode] = useState<'AND' | 'OR'>('OR') + const [searchTerm, setSearchTerm] = useState('') + + // Aggregate tags from all nodes + const tags = useMemo(() => { + const tagMap = new Map }>() + + nodes.forEach((node) => { + node.tags.forEach((tag) => { + const existing = tagMap.get(tag) + if (existing) { + existing.count++ + existing.types[node.type] = (existing.types[node.type] || 0) + 1 + } else { + tagMap.set(tag, { + count: 1, + types: { [node.type]: 1 }, + }) + } + }) + }) + + // Convert to array and calculate frequencies + const maxCount = Math.max(...Array.from(tagMap.values()).map((t) => t.count), 1) + + const tagArray: TagData[] = Array.from(tagMap.entries()) + .map(([tag, data]) => { + // Find dominant type (most common type for this tag) + const dominantType = Object.entries(data.types).sort((a, b) => b[1] - a[1])[0]?.[0] || 'Memory' + + return { + tag, + count: data.count, + frequency: data.count / maxCount, + dominantType, + types: data.types, + } + }) + .sort((a, b) => b.count - a.count) + .slice(0, maxTags) + + return tagArray + }, [nodes, maxTags]) + + // Filter tags by search term + const filteredTags = useMemo(() => { + if (!searchTerm) return tags + const lower = searchTerm.toLowerCase() + return tags.filter((t) => t.tag.toLowerCase().includes(lower)) + }, [tags, searchTerm]) + + // Get filtered node IDs based on selected tags + const filteredNodeIds = useMemo(() => { + if (selectedTags.size === 0) { + return new Set(nodes.map((n) => n.id)) + } + + const selectedTagsArray = Array.from(selectedTags) + + return new Set( + nodes + .filter((node) => { + if (filterMode === 'AND') { + // Node must have ALL selected tags + return selectedTagsArray.every((tag) => node.tags.includes(tag)) + } else { + // Node must have ANY selected tag + return selectedTagsArray.some((tag) => node.tags.includes(tag)) + } + }) + .map((n) => n.id) + ) + }, [nodes, selectedTags, filterMode]) + + // Actions + const toggleTag = useCallback((tag: string) => { + setSelectedTags((prev) => { + const next = new Set(prev) + if (next.has(tag)) { + next.delete(tag) + } else { + next.add(tag) + } + return next + }) + }, []) + + const selectTag = useCallback((tag: string) => { + setSelectedTags((prev) => new Set(prev).add(tag)) + }, []) + + const deselectTag = useCallback((tag: string) => { + setSelectedTags((prev) => { + const next = new Set(prev) + next.delete(tag) + return next + }) + }, []) + + const clearSelection = useCallback(() => { + setSelectedTags(new Set()) + }, []) + + const toggleFilterMode = useCallback(() => { + setFilterMode((prev) => (prev === 'AND' ? 'OR' : 'AND')) + }, []) + + return { + tags, + filteredTags, + selectedTags, + filterMode, + filteredNodeIds, + hasActiveFilter: selectedTags.size > 0, + toggleTag, + selectTag, + deselectTag, + clearSelection, + toggleFilterMode, + setFilterMode, + searchTerm, + setSearchTerm, + } +} diff --git a/packages/graph-viewer/src/hooks/useTimeTravel.ts b/packages/graph-viewer/src/hooks/useTimeTravel.ts new file mode 100644 index 0000000..316986e --- /dev/null +++ b/packages/graph-viewer/src/hooks/useTimeTravel.ts @@ -0,0 +1,306 @@ +/** + * useTimeTravel - Navigate memories chronologically + * + * Provides time-based filtering and playback controls to watch + * the memory graph evolve over time. + */ + +import { useState, useCallback, useMemo, useRef, useEffect } from 'react' +import type { GraphNode } from '../lib/types' + +export interface TimeTravelState { + isActive: boolean + isPlaying: boolean + currentTime: number // Unix timestamp in ms + playbackSpeed: number // 0.5, 1, 2, 4 + minTime: number + maxTime: number +} + +interface UseTimeTravelOptions { + nodes: GraphNode[] + enabled?: boolean +} + +interface UseTimeTravelReturn { + // State + state: TimeTravelState + isActive: boolean + isPlaying: boolean + currentTime: number + currentDate: Date + playbackSpeed: number + minTime: number + maxTime: number + progress: number // 0-1 progress through timeline + + // Filtered data + visibleNodes: Set + visibleCount: number + totalCount: number + + // Actions + activate: () => void + deactivate: () => void + toggleActive: () => void + play: () => void + pause: () => void + togglePlay: () => void + setTime: (time: number) => void + setProgress: (progress: number) => void + stepForward: () => void + stepBackward: () => void + setSpeed: (speed: number) => void + cycleSpeed: () => void + goToStart: () => void + goToEnd: () => void +} + +const SPEEDS = [0.5, 1, 2, 4] +const DEFAULT_STEP_MS = 24 * 60 * 60 * 1000 // 1 day + +export function useTimeTravel({ nodes, enabled: _enabled = true }: UseTimeTravelOptions): UseTimeTravelReturn { + // Calculate time bounds from nodes + const { minTime, maxTime } = useMemo(() => { + if (nodes.length === 0) { + const now = Date.now() + return { minTime: now - 30 * 24 * 60 * 60 * 1000, maxTime: now } + } + + let min = Infinity + let max = -Infinity + + nodes.forEach((node) => { + const time = new Date(node.timestamp).getTime() + if (!isNaN(time)) { + if (time < min) min = time + if (time > max) max = time + } + }) + + // If no valid timestamps, use last 30 days + if (min === Infinity) { + const now = Date.now() + return { minTime: now - 30 * 24 * 60 * 60 * 1000, maxTime: now } + } + + return { minTime: min, maxTime: max } + }, [nodes]) + + const [state, setState] = useState({ + isActive: false, + isPlaying: false, + currentTime: maxTime, + playbackSpeed: 1, + minTime, + maxTime, + }) + + // Update bounds when nodes change + useEffect(() => { + setState((prev) => ({ + ...prev, + minTime, + maxTime, + currentTime: prev.isActive ? prev.currentTime : maxTime, + })) + }, [minTime, maxTime]) + + // Animation frame ref for playback + const animationRef = useRef(null) + const lastFrameTimeRef = useRef(0) + + // Playback loop + useEffect(() => { + if (!state.isActive || !state.isPlaying) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + return + } + + const animate = (frameTime: number) => { + const deltaMs = frameTime - lastFrameTimeRef.current + lastFrameTimeRef.current = frameTime + + // Skip if too much time passed (tab was hidden) + if (deltaMs > 500) { + animationRef.current = requestAnimationFrame(animate) + return + } + + // Calculate time advancement + // At 1x speed, advance 1 day per second of real time + const timeAdvance = (deltaMs / 1000) * DEFAULT_STEP_MS * state.playbackSpeed + + setState((prev) => { + const newTime = prev.currentTime + timeAdvance + + // Stop at end + if (newTime >= prev.maxTime) { + return { ...prev, currentTime: prev.maxTime, isPlaying: false } + } + + return { ...prev, currentTime: newTime } + }) + + animationRef.current = requestAnimationFrame(animate) + } + + lastFrameTimeRef.current = performance.now() + animationRef.current = requestAnimationFrame(animate) + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + } + }, [state.isActive, state.isPlaying, state.playbackSpeed]) + + // Calculate visible nodes + const visibleNodes = useMemo(() => { + if (!state.isActive) { + return new Set(nodes.map((n) => n.id)) + } + + const visible = new Set() + nodes.forEach((node) => { + const nodeTime = new Date(node.timestamp).getTime() + if (!isNaN(nodeTime) && nodeTime <= state.currentTime) { + visible.add(node.id) + } + }) + + return visible + }, [nodes, state.isActive, state.currentTime]) + + // Actions + const activate = useCallback(() => { + setState((prev) => ({ + ...prev, + isActive: true, + currentTime: prev.minTime, // Start from beginning + })) + }, []) + + const deactivate = useCallback(() => { + setState((prev) => ({ + ...prev, + isActive: false, + isPlaying: false, + currentTime: prev.maxTime, + })) + }, []) + + const toggleActive = useCallback(() => { + setState((prev) => { + if (prev.isActive) { + return { ...prev, isActive: false, isPlaying: false, currentTime: prev.maxTime } + } + return { ...prev, isActive: true, currentTime: prev.minTime } + }) + }, []) + + const play = useCallback(() => { + setState((prev) => ({ ...prev, isPlaying: true })) + }, []) + + const pause = useCallback(() => { + setState((prev) => ({ ...prev, isPlaying: false })) + }, []) + + const togglePlay = useCallback(() => { + setState((prev) => ({ ...prev, isPlaying: !prev.isPlaying })) + }, []) + + const setTime = useCallback((time: number) => { + setState((prev) => ({ + ...prev, + currentTime: Math.max(prev.minTime, Math.min(prev.maxTime, time)), + })) + }, []) + + const setProgress = useCallback((progress: number) => { + setState((prev) => { + const range = prev.maxTime - prev.minTime + const time = prev.minTime + range * Math.max(0, Math.min(1, progress)) + return { ...prev, currentTime: time } + }) + }, []) + + const stepForward = useCallback(() => { + setState((prev) => ({ + ...prev, + currentTime: Math.min(prev.maxTime, prev.currentTime + DEFAULT_STEP_MS), + })) + }, []) + + const stepBackward = useCallback(() => { + setState((prev) => ({ + ...prev, + currentTime: Math.max(prev.minTime, prev.currentTime - DEFAULT_STEP_MS), + })) + }, []) + + const setSpeed = useCallback((speed: number) => { + setState((prev) => ({ ...prev, playbackSpeed: speed })) + }, []) + + const cycleSpeed = useCallback(() => { + setState((prev) => { + const currentIndex = SPEEDS.indexOf(prev.playbackSpeed) + const nextIndex = (currentIndex + 1) % SPEEDS.length + return { ...prev, playbackSpeed: SPEEDS[nextIndex] } + }) + }, []) + + const goToStart = useCallback(() => { + setState((prev) => ({ ...prev, currentTime: prev.minTime })) + }, []) + + const goToEnd = useCallback(() => { + setState((prev) => ({ ...prev, currentTime: prev.maxTime })) + }, []) + + // Calculate progress + const progress = useMemo(() => { + const range = state.maxTime - state.minTime + if (range === 0) return 1 + return (state.currentTime - state.minTime) / range + }, [state.currentTime, state.minTime, state.maxTime]) + + return { + // State + state, + isActive: state.isActive, + isPlaying: state.isPlaying, + currentTime: state.currentTime, + currentDate: new Date(state.currentTime), + playbackSpeed: state.playbackSpeed, + minTime: state.minTime, + maxTime: state.maxTime, + progress, + + // Filtered data + visibleNodes, + visibleCount: visibleNodes.size, + totalCount: nodes.length, + + // Actions + activate, + deactivate, + toggleActive, + play, + pause, + togglePlay, + setTime, + setProgress, + stepForward, + stepBackward, + setSpeed, + cycleSpeed, + goToStart, + goToEnd, + } +} diff --git a/packages/graph-viewer/src/index.css b/packages/graph-viewer/src/index.css index c59fda9..3c24673 100644 --- a/packages/graph-viewer/src/index.css +++ b/packages/graph-viewer/src/index.css @@ -61,3 +61,14 @@ body { .glow-purple { box-shadow: 0 0 20px rgba(139, 92, 246, 0.3); } + +/* Lasso selection animated dash */ +@keyframes dash { + to { + stroke-dashoffset: -24; + } +} + +.animate-dash { + animation: dash 0.5s linear infinite; +} diff --git a/packages/graph-viewer/src/lib/sounds.ts b/packages/graph-viewer/src/lib/sounds.ts new file mode 100644 index 0000000..eaf06cc --- /dev/null +++ b/packages/graph-viewer/src/lib/sounds.ts @@ -0,0 +1,570 @@ +/** + * Sound Design System - Procedural audio for the graph viewer + * + * Uses Web Audio API to generate all sounds procedurally. + * No external audio files needed - everything is synthesized. + * + * Sound palette: + * - Node select: Soft crystalline ping (pitch varies with importance) + * - Node hover: Whisper-quiet high tone + * - Zoom: Gentle whoosh (pitch direction matches zoom direction) + * - Search: Soft keyboard click + * - Bookmark save: Camera shutter sound + * - Delete: Low thud with decay + * - Error: Gentle dissonant tone + * - Success: Ascending chime + */ + +type SoundType = + | 'select' + | 'hover' + | 'zoomIn' + | 'zoomOut' + | 'search' + | 'bookmark' + | 'delete' + | 'error' + | 'success' + | 'pathFound' + | 'timeTravel' + | 'lasso' + +interface SoundSettings { + masterVolume: number // 0-1 + enabled: boolean + individualSounds: Record +} + +const DEFAULT_SETTINGS: SoundSettings = { + masterVolume: 0.3, + enabled: false, // Default off per spec + individualSounds: { + select: true, + hover: true, + zoomIn: true, + zoomOut: true, + search: true, + bookmark: true, + delete: true, + error: true, + success: true, + pathFound: true, + timeTravel: true, + lasso: true, + }, +} + +class SoundManager { + private audioContext: AudioContext | null = null + private settings: SoundSettings = DEFAULT_SETTINGS + private lastHoverTime = 0 + private hoverThrottle = 50 // ms between hover sounds + + constructor() { + // Load settings from localStorage + this.loadSettings() + } + + private getContext(): AudioContext | null { + if (!this.audioContext) { + try { + this.audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() + } catch { + console.warn('Web Audio API not supported') + return null + } + } + + // Resume if suspended (browser autoplay policy) + if (this.audioContext.state === 'suspended') { + this.audioContext.resume() + } + + return this.audioContext + } + + private loadSettings() { + try { + const stored = localStorage.getItem('graph-viewer-sound-settings') + if (stored) { + this.settings = { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } + } + } catch { + // Use defaults + } + } + + saveSettings() { + try { + localStorage.setItem('graph-viewer-sound-settings', JSON.stringify(this.settings)) + } catch { + // Ignore storage errors + } + } + + getSettings(): SoundSettings { + return { ...this.settings } + } + + setMasterVolume(volume: number) { + this.settings.masterVolume = Math.max(0, Math.min(1, volume)) + this.saveSettings() + } + + setEnabled(enabled: boolean) { + this.settings.enabled = enabled + this.saveSettings() + } + + toggleSound(type: SoundType, enabled: boolean) { + this.settings.individualSounds[type] = enabled + this.saveSettings() + } + + private canPlay(type: SoundType): boolean { + return this.settings.enabled && this.settings.individualSounds[type] + } + + private getVolume(): number { + return this.settings.masterVolume * 0.5 // Overall quieter + } + + /** + * Node selection - crystalline ping + * @param importance 0-1, affects pitch (higher = higher pitch) + */ + playSelect(importance = 0.5) { + if (!this.canPlay('select')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.4 + + // Base frequency varies with importance (400Hz - 800Hz) + const baseFreq = 400 + importance * 400 + + // Create oscillator for main tone + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(baseFreq, now) + osc.frequency.exponentialRampToValueAtTime(baseFreq * 1.5, now + 0.1) + + // Add harmonic + const osc2 = ctx.createOscillator() + osc2.type = 'sine' + osc2.frequency.setValueAtTime(baseFreq * 2, now) + osc2.frequency.exponentialRampToValueAtTime(baseFreq * 3, now + 0.08) + + // Gain envelope + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, now) + gain.gain.linearRampToValueAtTime(volume, now + 0.01) + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3) + + const gain2 = ctx.createGain() + gain2.gain.setValueAtTime(0, now) + gain2.gain.linearRampToValueAtTime(volume * 0.3, now + 0.01) + gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.2) + + osc.connect(gain).connect(ctx.destination) + osc2.connect(gain2).connect(ctx.destination) + + osc.start(now) + osc.stop(now + 0.35) + osc2.start(now) + osc2.stop(now + 0.25) + } + + /** + * Node hover - whisper-quiet high tone + */ + playHover() { + if (!this.canPlay('hover')) return + + // Throttle hover sounds + const now = Date.now() + if (now - this.lastHoverTime < this.hoverThrottle) return + this.lastHoverTime = now + + const ctx = this.getContext() + if (!ctx) return + + const time = ctx.currentTime + const volume = this.getVolume() * 0.08 // Very quiet + + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(1200, time) + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, time) + gain.gain.linearRampToValueAtTime(volume, time + 0.02) + gain.gain.exponentialRampToValueAtTime(0.001, time + 0.08) + + osc.connect(gain).connect(ctx.destination) + osc.start(time) + osc.stop(time + 0.1) + } + + /** + * Zoom - gentle whoosh + * @param zoomIn true for zoom in, false for zoom out + */ + playZoom(zoomIn: boolean) { + if (!this.canPlay(zoomIn ? 'zoomIn' : 'zoomOut')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.15 + + // Noise-based whoosh using filtered noise + const bufferSize = ctx.sampleRate * 0.15 + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate) + const data = buffer.getChannelData(0) + + // Generate noise + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1 + } + + const source = ctx.createBufferSource() + source.buffer = buffer + + // Bandpass filter for tonal quality + const filter = ctx.createBiquadFilter() + filter.type = 'bandpass' + filter.Q.value = 2 + + // Frequency sweep direction based on zoom + if (zoomIn) { + filter.frequency.setValueAtTime(200, now) + filter.frequency.exponentialRampToValueAtTime(800, now + 0.15) + } else { + filter.frequency.setValueAtTime(800, now) + filter.frequency.exponentialRampToValueAtTime(200, now + 0.15) + } + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, now) + gain.gain.linearRampToValueAtTime(volume, now + 0.02) + gain.gain.linearRampToValueAtTime(0, now + 0.15) + + source.connect(filter).connect(gain).connect(ctx.destination) + source.start(now) + } + + /** + * Search keystroke - soft click + */ + playSearch() { + if (!this.canPlay('search')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.1 + + // Short noise burst + const bufferSize = ctx.sampleRate * 0.02 + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate) + const data = buffer.getChannelData(0) + + for (let i = 0; i < bufferSize; i++) { + // Decaying envelope baked in + const env = 1 - i / bufferSize + data[i] = (Math.random() * 2 - 1) * env * env + } + + const source = ctx.createBufferSource() + source.buffer = buffer + + const filter = ctx.createBiquadFilter() + filter.type = 'highpass' + filter.frequency.value = 2000 + + const gain = ctx.createGain() + gain.gain.value = volume + + source.connect(filter).connect(gain).connect(ctx.destination) + source.start(now) + } + + /** + * Bookmark save - camera shutter + */ + playBookmark() { + if (!this.canPlay('bookmark')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.2 + + // First click + const click1Size = ctx.sampleRate * 0.015 + const click1 = ctx.createBuffer(1, click1Size, ctx.sampleRate) + const click1Data = click1.getChannelData(0) + for (let i = 0; i < click1Size; i++) { + const env = Math.exp(-i / (ctx.sampleRate * 0.003)) + click1Data[i] = (Math.random() * 2 - 1) * env + } + + // Second click (slightly delayed) + const click2Size = ctx.sampleRate * 0.012 + const click2 = ctx.createBuffer(1, click2Size, ctx.sampleRate) + const click2Data = click2.getChannelData(0) + for (let i = 0; i < click2Size; i++) { + const env = Math.exp(-i / (ctx.sampleRate * 0.004)) + click2Data[i] = (Math.random() * 2 - 1) * env + } + + const source1 = ctx.createBufferSource() + source1.buffer = click1 + const source2 = ctx.createBufferSource() + source2.buffer = click2 + + const gain = ctx.createGain() + gain.gain.value = volume + + source1.connect(gain).connect(ctx.destination) + source2.connect(gain) + + source1.start(now) + source2.start(now + 0.05) + } + + /** + * Delete - low thud + */ + playDelete() { + if (!this.canPlay('delete')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.3 + + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(80, now) + osc.frequency.exponentialRampToValueAtTime(40, now + 0.15) + + const gain = ctx.createGain() + gain.gain.setValueAtTime(volume, now) + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2) + + // Add subtle noise for "thump" texture + const noiseSize = ctx.sampleRate * 0.05 + const noise = ctx.createBuffer(1, noiseSize, ctx.sampleRate) + const noiseData = noise.getChannelData(0) + for (let i = 0; i < noiseSize; i++) { + const env = Math.exp(-i / (ctx.sampleRate * 0.01)) + noiseData[i] = (Math.random() * 2 - 1) * env + } + const noiseSource = ctx.createBufferSource() + noiseSource.buffer = noise + + const lowpass = ctx.createBiquadFilter() + lowpass.type = 'lowpass' + lowpass.frequency.value = 200 + + const noiseGain = ctx.createGain() + noiseGain.gain.value = volume * 0.5 + + osc.connect(gain).connect(ctx.destination) + noiseSource.connect(lowpass).connect(noiseGain).connect(ctx.destination) + + osc.start(now) + osc.stop(now + 0.25) + noiseSource.start(now) + } + + /** + * Error - gentle dissonant tone + */ + playError() { + if (!this.canPlay('error')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.2 + + // Slightly dissonant interval + const osc1 = ctx.createOscillator() + osc1.type = 'sine' + osc1.frequency.value = 280 + + const osc2 = ctx.createOscillator() + osc2.type = 'sine' + osc2.frequency.value = 340 // Minor second above + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, now) + gain.gain.linearRampToValueAtTime(volume, now + 0.02) + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4) + + osc1.connect(gain).connect(ctx.destination) + osc2.connect(gain) + + osc1.start(now) + osc1.stop(now + 0.45) + osc2.start(now) + osc2.stop(now + 0.45) + } + + /** + * Success - ascending chime + */ + playSuccess() { + if (!this.canPlay('success')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.25 + + // Ascending arpeggio + const notes = [523.25, 659.25, 783.99] // C5, E5, G5 + notes.forEach((freq, i) => { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = freq + + const gain = ctx.createGain() + const startTime = now + i * 0.08 + gain.gain.setValueAtTime(0, startTime) + gain.gain.linearRampToValueAtTime(volume * (1 - i * 0.2), startTime + 0.02) + gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.3) + + osc.connect(gain).connect(ctx.destination) + osc.start(startTime) + osc.stop(startTime + 0.35) + }) + } + + /** + * Path found - magical discovery sound + */ + playPathFound() { + if (!this.canPlay('pathFound')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.2 + + // Shimmering ascending tone + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(400, now) + osc.frequency.exponentialRampToValueAtTime(800, now + 0.3) + + // Add subtle harmonics + const osc2 = ctx.createOscillator() + osc2.type = 'sine' + osc2.frequency.setValueAtTime(800, now) + osc2.frequency.exponentialRampToValueAtTime(1600, now + 0.3) + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, now) + gain.gain.linearRampToValueAtTime(volume, now + 0.05) + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.5) + + const gain2 = ctx.createGain() + gain2.gain.setValueAtTime(0, now) + gain2.gain.linearRampToValueAtTime(volume * 0.3, now + 0.05) + gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.4) + + osc.connect(gain).connect(ctx.destination) + osc2.connect(gain2).connect(ctx.destination) + + osc.start(now) + osc.stop(now + 0.55) + osc2.start(now) + osc2.stop(now + 0.45) + } + + /** + * Time travel - whooshy temporal effect + */ + playTimeTravel() { + if (!this.canPlay('timeTravel')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.15 + + // Sweeping filter on noise + const bufferSize = ctx.sampleRate * 0.4 + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate) + const data = buffer.getChannelData(0) + + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1 + } + + const source = ctx.createBufferSource() + source.buffer = buffer + + const filter = ctx.createBiquadFilter() + filter.type = 'bandpass' + filter.Q.value = 5 + filter.frequency.setValueAtTime(500, now) + filter.frequency.exponentialRampToValueAtTime(2000, now + 0.2) + filter.frequency.exponentialRampToValueAtTime(300, now + 0.4) + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, now) + gain.gain.linearRampToValueAtTime(volume, now + 0.05) + gain.gain.linearRampToValueAtTime(0, now + 0.4) + + source.connect(filter).connect(gain).connect(ctx.destination) + source.start(now) + } + + /** + * Lasso selection complete + */ + playLasso() { + if (!this.canPlay('lasso')) return + + const ctx = this.getContext() + if (!ctx) return + + const now = ctx.currentTime + const volume = this.getVolume() * 0.15 + + // Quick ascending sweep + const osc = ctx.createOscillator() + osc.type = 'triangle' + osc.frequency.setValueAtTime(300, now) + osc.frequency.exponentialRampToValueAtTime(600, now + 0.1) + + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, now) + gain.gain.linearRampToValueAtTime(volume, now + 0.02) + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15) + + osc.connect(gain).connect(ctx.destination) + osc.start(now) + osc.stop(now + 0.2) + } +} + +// Singleton instance +export const soundManager = new SoundManager() + +export type { SoundType, SoundSettings } From d8db29145aa28b27809b9e01884e98590b13aee4 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 24 Dec 2025 17:37:27 +0100 Subject: [PATCH 36/47] feat: Refactor hand cursor and force layout logic Introduces a new useHandCursor hook for simplified hand cursor tracking, replacing complex pointing and pinch logic in GraphCanvas. Refactors force layout logic in useForceLayout to use a module-level cache for improved stability and performance, and updates related components for more stable data references and improved event handling. Also improves lasso overlay usability and hand overlay depth feedback. --- packages/graph-viewer/src/App.tsx | 62 +-- .../src/components/GraphCanvas.tsx | 361 ++++++++++-------- .../src/components/Hand2DOverlay.tsx | 52 ++- .../src/components/LassoOverlay.tsx | 21 +- .../graph-viewer/src/hooks/useForceLayout.ts | 283 +++++++------- .../graph-viewer/src/hooks/useHandCursor.ts | 272 +++++++++++++ 6 files changed, 691 insertions(+), 360 deletions(-) create mode 100644 packages/graph-viewer/src/hooks/useHandCursor.ts diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 87c0d8e..9e343ca 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -106,6 +106,10 @@ function BoltIcon({ className }: { className?: string }) { ) } +// Stable empty arrays to prevent creating new references on every render +const EMPTY_NODES: GraphNode[] = [] +const EMPTY_EDGES: import('./lib/types').GraphEdge[] = [] + export default function App() { const { setToken, isAuthenticated } = useAuth() const [selectedNode, setSelectedNode] = useState(null) @@ -240,9 +244,13 @@ export default function App() { enabled: isAuthenticated, }) + // Stable data references - use EMPTY constants when data not loaded + const nodes = data?.nodes ?? EMPTY_NODES + const edges = data?.edges ?? EMPTY_EDGES + // Tag Cloud const tagCloud = useTagCloud({ - nodes: data?.nodes ?? [], + nodes, typeColors: data?.meta?.type_colors, }) @@ -251,13 +259,13 @@ export default function App() { // Pathfinding const pathfinding = usePathfinding({ - nodes: (data?.nodes ?? []) as any, - edges: data?.edges ?? [], + nodes: nodes as any, + edges, }) // Time Travel const timeTravel = useTimeTravel({ - nodes: data?.nodes ?? [], + nodes, enabled: isAuthenticated, }) @@ -270,18 +278,18 @@ export default function App() { } prevTimeTravelActive.current = timeTravel.isActive } - }, [timeTravel.isActive, sound]) + }, [timeTravel.isActive, sound.playTimeTravel]) // Get source and target nodes for pathfinding overlay const pathSourceNode = useMemo(() => { - if (!pathfinding.sourceId || !data?.nodes) return null - return (data.nodes as any[]).find(n => n.id === pathfinding.sourceId) ?? null - }, [pathfinding.sourceId, data?.nodes]) + if (!pathfinding.sourceId || nodes.length === 0) return null + return nodes.find(n => n.id === pathfinding.sourceId) ?? null + }, [pathfinding.sourceId, nodes]) const pathTargetNode = useMemo(() => { - if (!pathfinding.targetId || !data?.nodes) return null - return (data.nodes as any[]).find(n => n.id === pathfinding.targetId) ?? null - }, [pathfinding.targetId, data?.nodes]) + if (!pathfinding.targetId || nodes.length === 0) return null + return nodes.find(n => n.id === pathfinding.targetId) ?? null + }, [pathfinding.targetId, nodes]) // Bookmark handlers (must be after data is defined) const handleSaveBookmark = useCallback(() => { @@ -291,18 +299,18 @@ export default function App() { selectedNode?.id ) sound.playBookmark() - }, [addBookmark, cameraStateForBookmarks, selectedNode, sound]) + }, [addBookmark, cameraStateForBookmarks, selectedNode, sound.playBookmark]) const handleNavigateToBookmark = useCallback((bookmark: Bookmark) => { navigateForBookmarksRef.current?.(bookmark.position.x, bookmark.position.y) // If bookmark has a selected node, select it - if (bookmark.selectedNodeId && data?.nodes) { - const node = data.nodes.find(n => n.id === bookmark.selectedNodeId) + if (bookmark.selectedNodeId && nodes.length > 0) { + const node = nodes.find(n => n.id === bookmark.selectedNodeId) if (node) { setSelectedNode(node) } } - }, [data?.nodes]) + }, [nodes]) const handleRenameBookmark = useCallback((id: string, name: string) => { updateBookmark(id, { name }) @@ -327,14 +335,14 @@ export default function App() { sound.playSelect(node.importance ?? 0.5) } setSelectedNode(node) - }, [pathfinding.isSelectingTarget, pathfinding.completePathSelection, sound]) + }, [pathfinding.isSelectingTarget, pathfinding.completePathSelection, sound.playPathFound, sound.playSelect]) const handleNodeHover = useCallback((node: GraphNode | null) => { if (node) { sound.playHover() } setHoveredNode(node) - }, [sound]) + }, [sound.playHover]) // Radial menu handlers const handleNodeContextMenu = useCallback((node: GraphNode, screenPosition: { x: number; y: number }) => { @@ -410,7 +418,7 @@ export default function App() { selectedIds: newSelectedIds, } }) - }, [sound]) + }, [sound.playLasso]) const handleLassoCancel = useCallback(() => { setLassoState(prev => ({ @@ -429,9 +437,9 @@ export default function App() { // Get selected nodes from lasso const lassoSelectedNodes = useMemo(() => { - if (!data?.nodes || lassoState.selectedIds.size === 0) return [] - return data.nodes.filter(n => lassoState.selectedIds.has(n.id)) - }, [data?.nodes, lassoState.selectedIds]) + if (nodes.length === 0 || lassoState.selectedIds.size === 0) return [] + return nodes.filter(n => lassoState.selectedIds.has(n.id)) + }, [nodes, lassoState.selectedIds]) const handleSearch = useCallback((term: string) => { // Play search sound on typing (only if term changed and is not empty) @@ -439,7 +447,7 @@ export default function App() { sound.playSearch() } setSearchTerm(term) - }, [sound]) + }, [sound.playSearch]) const handleFilterChange = useCallback((newFilters: Partial) => { setFilters(prev => ({ ...prev, ...newFilters })) @@ -519,7 +527,7 @@ export default function App() { }, [selectedNode, pathfinding.startPathSelection]) const { shortcuts } = useKeyboardNavigation({ - nodes: (data?.nodes ?? []) as any, + nodes: nodes as any, selectedNode, onNodeSelect: handleNodeSelect, onReheat: handleReheat, @@ -714,8 +722,8 @@ export default function App() { )}
@@ -906,7 +914,7 @@ export default function App() { selectedTags={tagCloud.selectedTags} filterMode={tagCloud.filterMode} filteredCount={tagCloud.filteredNodeIds.size} - totalCount={data?.nodes?.length ?? 0} + totalCount={nodes.length} onToggleTag={tagCloud.toggleTag} onClearSelection={tagCloud.clearSelection} onToggleFilterMode={tagCloud.toggleFilterMode} diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 029ed71..4949f22 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -27,6 +27,7 @@ import { useHandGestures, GestureState } from '../hooks/useHandGestures' import { useIPhoneHandTracking } from '../hooks/useIPhoneHandTracking' import { useHandInteraction } from '../hooks/useHandInteraction' import { useHandLockAndGrab } from '../hooks/useHandLockAndGrab' +import { useHandCursor } from '../hooks/useHandCursor' import { ExpandedNodeView } from './ExpandedNodeView' import type { GraphNode, @@ -353,6 +354,9 @@ function Scene({ const { camera } = useThree() const { nodes: layoutNodes, isSimulating, reheat } = useForceLayout({ nodes, edges, forceConfig }) + // DEBUG: Log node counts + console.log('🔍 Scene render - input nodes:', nodes.length, 'layoutNodes:', layoutNodes.length, 'isSimulating:', isSimulating) + // Focus mode - compute depth-based opacity for spotlight effect const focusStates = useFocusMode( layoutNodes, @@ -560,12 +564,16 @@ function Scene({ // New UI: open-palm acquire/lock + fist grab controls (single-hand for now) const { lock: handLock, deltas: grabDeltas } = useHandLockAndGrab(gestureState, gestureControlEnabled) - // Pointing ray + pinch-click - const [aimHit, setAimHit] = useState(null) - const [aimWorldPoint, setAimWorldPoint] = useState<{ x: number; y: number; z: number } | null>(null) - const aimRayRef = useRef<{ origin: THREE.Vector3; direction: THREE.Vector3 } | null>(null) - const pinchDownRef = useRef(false) - const pressedNodeRef = useRef(null) + // Simplified hand cursor (replaces complex pointing/pinch logic) + const cursorState = useHandCursor(gestureState, { enabled: gestureControlEnabled }) + + // Track cursor hit state for rendering + const [cursorHit, setCursorHit] = useState(null) + const [cursorWorldPoint, setCursorWorldPoint] = useState<{ x: number; y: number; z: number } | null>(null) + const raycasterRef = useRef(new THREE.Raycaster()) + + // Helper to check if hand is currently grabbing + const isGrabbing = handLock.mode === 'locked' && handLock.grabbed const nodeSpheres: NodeSphere[] = useMemo(() => { return layoutNodes.map((n) => ({ @@ -607,58 +615,40 @@ function Scene({ [layoutNodes] ) - // Pointing + pinch click (Meta-style: index points, pinch clicks; fist grabs) + // Simplified cursor-based pointing (index fingertip = cursor, pinch = click) + // Uses useHandCursor hook for immediate, no-lock cursor tracking useEffect(() => { - if (!gestureControlEnabled) return - - if (handLock.mode !== 'locked') { - setAimHit(null) - setAimWorldPoint(null) - aimRayRef.current = null - pinchDownRef.current = false - pressedNodeRef.current = null - onNodeHover(null) + if (!gestureControlEnabled || !cursorState.isActive || !cursorState.screenPosition) { + setCursorHit(null) + setCursorWorldPoint(null) + // Only clear hover if we're in cursor mode and lost tracking + if (gestureControlEnabled && !cursorState.isActive) { + onNodeHover(null) + } return } - const m = handLock.metrics - const isAimPose = !handLock.grabbed && m.point > 0.55 - if (!isAimPose) { - setAimHit(null) - setAimWorldPoint(null) - aimRayRef.current = null - pinchDownRef.current = false - pressedNodeRef.current = null - onNodeHover(null) + // Skip cursor updates when grabbing (fist gesture controls camera, not cursor) + if (isGrabbing) { + setCursorHit(null) + setCursorWorldPoint(null) return } - const handData = handLock.hand === 'right' ? gestureState.rightHand : gestureState.leftHand - if (!handData) return - - const indexTip = handData.landmarks[8] - - // Convert to screen-space (0..1, origin bottom-left) - // Both MediaPipe and iPhone are in "camera image" coords (x left->right, y top->bottom). - // For intuitive interaction, mirror X like a selfie preview, and invert Y to bottom-left origin. - const screenX = 1 - indexTip.x - const screenY = 1 - indexTip.y + const { x: ndcX, y: ndcY } = cursorState.screenPosition - // Build a ray from the camera through the screen point - const ndcX = screenX * 2 - 1 - const ndcY = screenY * 2 - 1 - const worldPoint = new THREE.Vector3(ndcX, ndcY, 0.5).unproject(camera) - const direction = worldPoint.sub(camera.position).normalize() - const origin = camera.position.clone() - aimRayRef.current = { origin, direction: direction.clone() } + // Build ray from camera through NDC point + const raycaster = raycasterRef.current + raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera) - // Hit test against spheres in graph GROUP local coordinates + // Hit test against node spheres using findNodeHit (works in group local space) const group = groupRef.current let hit: NodeHit | null = null + if (group) { const inv = group.matrixWorld.clone().invert() - const localOrigin = origin.clone().applyMatrix4(inv) - const localDir = direction.clone().transformDirection(inv) + const localOrigin = raycaster.ray.origin.clone().applyMatrix4(inv) + const localDir = raycaster.ray.direction.clone().transformDirection(inv) hit = findNodeHit( { origin: { x: localOrigin.x, y: localOrigin.y, z: localOrigin.z }, @@ -669,8 +659,7 @@ function Scene({ ) } - // Snap-to-node: if we have a hit, bias the aim point to the node CENTER (feels "magnetic") - // This also makes click-open deterministic. + // Snap to node center for deterministic selection const snapNode = hit ? (nodeById.get(hit.nodeId) ?? null) : null const snappedHit: NodeHit | null = hit && snapNode @@ -684,64 +673,53 @@ function Scene({ } : hit - setAimHit(snappedHit) + setCursorHit(snappedHit) - // World-space aim point for rendering (snapped node center if present; otherwise plane intersection near graph center) - let aimPointWorld: THREE.Vector3 | null = null + // Calculate world-space cursor position for rendering + let worldPoint: THREE.Vector3 | null = null if (group && snappedHit) { - aimPointWorld = new THREE.Vector3( + worldPoint = new THREE.Vector3( snappedHit.point.x, snappedHit.point.y, snappedHit.point.z ).applyMatrix4(group.matrixWorld) } else if (group) { + // No hit - intersect with plane at graph center const center = group.getWorldPosition(new THREE.Vector3()) const normal = camera.getWorldDirection(new THREE.Vector3()).normalize() const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, center) - const ray = new THREE.Ray(origin, direction) - aimPointWorld = ray.intersectPlane(plane, new THREE.Vector3()) || null + worldPoint = raycaster.ray.intersectPlane(plane, new THREE.Vector3()) || null } - if (!aimPointWorld) { - aimPointWorld = origin.clone().add(direction.clone().multiplyScalar(250)) + if (!worldPoint) { + worldPoint = raycaster.ray.origin.clone().add(raycaster.ray.direction.clone().multiplyScalar(200)) } - setAimWorldPoint({ x: aimPointWorld.x, y: aimPointWorld.y, z: aimPointWorld.z }) + setCursorWorldPoint({ x: worldPoint.x, y: worldPoint.y, z: worldPoint.z }) - // Hover highlight + // Update hover state const hoverNode = snappedHit ? (nodeById.get(snappedHit.nodeId) ?? null) : null onNodeHover(hoverNode as GraphNode | null) - - // Pinch click (only when aiming). Trigger on release to allow cancel. - const pinchActive = m.pinch > 0.75 - if (pinchActive && !pinchDownRef.current) { - pinchDownRef.current = true - pressedNodeRef.current = snappedHit?.nodeId ?? null - return - } - - if (!pinchActive && pinchDownRef.current) { - pinchDownRef.current = false - const pressed = pressedNodeRef.current - pressedNodeRef.current = null - if (pressed && snappedHit?.nodeId === pressed) { - selectNodeById(pressed, snappedHit.point) - } - } }, [ gestureControlEnabled, + cursorState.isActive, + cursorState.screenPosition?.x, + cursorState.screenPosition?.y, + isGrabbing, camera, nodeSpheres, nodeById, onNodeHover, - selectNodeById, - gestureState.leftHand, - gestureState.rightHand, - handLock.mode, - (handLock as any).hand, - (handLock as any).grabbed, - (handLock as any).metrics?.point, - (handLock as any).metrics?.pinch, ]) + // Pinch-to-select: trigger on pinch DOWN (immediate response) + useEffect(() => { + if (!gestureControlEnabled) return + + // Select on pinch down (the moment pinch is detected) + if (cursorState.pinchState === 'down' && cursorHit) { + selectNodeById(cursorHit.nodeId, cursorHit.point) + } + }, [gestureControlEnabled, cursorState.pinchState, cursorHit, selectNodeById]) + // Filter nodes based on search const searchLower = searchTerm.toLowerCase() const matchingIds = useMemo(() => { @@ -886,6 +864,7 @@ function Scene({ {/* Camera controls */} - {/* Aim cursor + laser (Meta-style: index aims, pinch clicks). Laser only appears while pinching. */} + {/* Simplified hand cursor - just a dot at cursor position (no laser) */} {gestureControlEnabled && - handLock.mode === 'locked' && - !handLock.grabbed && - handLock.metrics.point > 0.55 && - aimWorldPoint && ( - <> - {/* Cursor at aim point (hit point when hovering a node; otherwise a plane in front of the cloud) */} - - 0.75 ? (aimHit ? 0.9 : 0.7) : (aimHit ? 0.55 : 0.4), 16, 16]} /> - 0.75 ? '#ffffff' : aimHit ? '#fbbf24' : '#94a3b8'} - transparent - opacity={0.9} - /> - - - {/* Laser beam while AIMING (brighter while pinching) */} - {aimRayRef.current && ( - - - - - 0.75 ? '#ffffff' : aimHit ? '#fbbf24' : '#60a5fa'} - transparent - opacity={handLock.metrics.pinch > 0.75 ? 0.9 : 0.35} - /> - - )} - + cursorState.isActive && + !isGrabbing && + cursorWorldPoint && ( + + {/* Size: larger when hovering or pinching */} + 0.7 + ? (cursorHit ? 1.0 : 0.8) // Pinching: large + : cursorHit + ? 0.6 // Hovering: medium + : 0.4, // Idle: small + 16, 16 + ]} /> + {/* Color: blue (idle) → gold (hover) → white (pinch) */} + 0.7 + ? '#ffffff' // Pinching: white + : cursorHit + ? '#fbbf24' // Hovering: gold + : '#60a5fa' // Idle: blue + } + transparent + opacity={cursorState.pinchStrength > 0.7 ? 1.0 : 0.85} + /> + )} {/* Post-processing effects - conditional based on performance mode */} @@ -1264,7 +1225,81 @@ function InstancedNodes({ hasTagFilter = false, }: InstancedNodesProps) { const meshRef = useRef(null) - const { camera, raycaster, pointer } = useThree() + const { camera, raycaster, pointer, gl } = useThree() + + // Node lookup for raycasting - must be defined before useEffect that uses it + const nodeIndexMap = useMemo(() => { + const map = new Map() + nodes.forEach((node, index) => { + map.set(index, node) + }) + return map + }, [nodes]) + + // Track pointer for click detection (distinguish click vs drag) + const pointerDownRef = useRef<{ x: number; y: number; time: number } | null>(null) + + // DOM-level click handling (bypasses R3F's event system which doesn't work with OrbitControls) + useEffect(() => { + const canvas = gl.domElement + + const handlePointerDown = (e: PointerEvent) => { + pointerDownRef.current = { x: e.clientX, y: e.clientY, time: Date.now() } + } + + const handlePointerUp = (e: PointerEvent) => { + if (!meshRef.current || !pointerDownRef.current) return + + const dx = e.clientX - pointerDownRef.current.x + const dy = e.clientY - pointerDownRef.current.y + const dt = Date.now() - pointerDownRef.current.time + const distance = Math.sqrt(dx * dx + dy * dy) + + // Consider it a click if moved less than 5px and less than 300ms + const isClick = distance < 5 && dt < 300 + + console.log('🖱️ DOM PointerUp - distance:', distance.toFixed(1), 'dt:', dt, 'isClick:', isClick) + + if (isClick) { + // Calculate NDC from event coordinates (R3F's pointer isn't updated for DOM events) + const rect = canvas.getBoundingClientRect() + const x = ((e.clientX - rect.left) / rect.width) * 2 - 1 + const y = -((e.clientY - rect.top) / rect.height) * 2 + 1 + + // Force update the mesh's world matrix for accurate raycasting + meshRef.current.updateMatrixWorld(true) + + const ndcVector = new THREE.Vector2(x, y) + raycaster.setFromCamera(ndcVector, camera) + const intersects = raycaster.intersectObject(meshRef.current) + + console.log('🖱️ Raycast - NDC:', x.toFixed(2), y.toFixed(2), 'intersects:', intersects.length, 'nodes:', nodeIndexMap.size) + + if (intersects.length > 0) { + const instanceId = intersects[0].instanceId + console.log('🖱️ Raycast hit instanceId:', instanceId) + if (instanceId !== undefined) { + const node = nodeIndexMap.get(instanceId) + console.log('🖱️ Found node:', node?.id) + if (node) { + // Toggle selection + onNodeSelect(selectedNode?.id === node.id ? null : node) + } + } + } + } + + pointerDownRef.current = null + } + + canvas.addEventListener('pointerdown', handlePointerDown) + canvas.addEventListener('pointerup', handlePointerUp) + + return () => { + canvas.removeEventListener('pointerdown', handlePointerDown) + canvas.removeEventListener('pointerup', handlePointerUp) + } + }, [gl, camera, raycaster, nodeIndexMap, onNodeSelect, selectedNode]) // Refs to hold latest time travel state (needed for useFrame closure) const timeTravelActiveRef = useRef(timeTravelActive) @@ -1290,18 +1325,23 @@ function InstancedNodes({ [] ) - // Node lookup for raycasting - const nodeIndexMap = useMemo(() => { - const map = new Map() - nodes.forEach((node, index) => { - map.set(index, node) - }) - return map - }, [nodes]) + // Animation state - recreate when node count changes + const nodeCount = nodes.length + const scalesRef = useRef(new Float32Array(0)) + const targetScalesRef = useRef(new Float32Array(0)) - // Animation state - const scalesRef = useRef(new Float32Array(nodes.length)) - const targetScalesRef = useRef(new Float32Array(nodes.length)) + // Resize scale arrays when node count changes + useEffect(() => { + if (scalesRef.current.length !== nodeCount) { + scalesRef.current = new Float32Array(nodeCount) + targetScalesRef.current = new Float32Array(nodeCount) + // Initialize scales to 1 + for (let i = 0; i < nodeCount; i++) { + scalesRef.current[i] = 1 + targetScalesRef.current[i] = 1 + } + } + }, [nodeCount]) // Temp objects for matrix calculations (reused to avoid GC) const tempMatrix = useMemo(() => new THREE.Matrix4(), []) @@ -1476,9 +1516,12 @@ function InstancedNodes({ [camera, pointer, raycaster, nodeIndexMap, onNodeHover] ) - const handleClick = useCallback( + const handleContextMenu = useCallback( (event: ThreeEvent) => { - if (!meshRef.current) return + if (!meshRef.current || !onNodeContextMenu) return + + // Prevent the browser's default context menu + event.nativeEvent.preventDefault() raycaster.setFromCamera(pointer, camera) const intersects = raycaster.intersectObject(meshRef.current) @@ -1489,49 +1532,55 @@ function InstancedNodes({ const node = nodeIndexMap.get(instanceId) if (node) { event.stopPropagation() - onNodeSelect(selectedNode?.id === node.id ? null : node) + // Get screen position from the native event + const screenPosition = { + x: event.nativeEvent.clientX, + y: event.nativeEvent.clientY, + } + onNodeContextMenu(node, screenPosition) } } } }, - [camera, pointer, raycaster, nodeIndexMap, onNodeSelect, selectedNode] + [camera, pointer, raycaster, nodeIndexMap, onNodeContextMenu] ) - const handleContextMenu = useCallback( + // R3F onClick handler - uses R3F's event system which works with OrbitControls + const handleClick = useCallback( (event: ThreeEvent) => { - if (!meshRef.current || !onNodeContextMenu) return + if (!meshRef.current) return - // Prevent the browser's default context menu - event.nativeEvent.preventDefault() + console.log('🖱️ R3F onClick triggered') raycaster.setFromCamera(pointer, camera) const intersects = raycaster.intersectObject(meshRef.current) + console.log('🖱️ R3F onClick - intersects:', intersects.length) + if (intersects.length > 0) { const instanceId = intersects[0].instanceId + console.log('🖱️ R3F onClick hit instanceId:', instanceId) if (instanceId !== undefined) { const node = nodeIndexMap.get(instanceId) + console.log('🖱️ R3F onClick found node:', node?.id) if (node) { event.stopPropagation() - // Get screen position from the native event - const screenPosition = { - x: event.nativeEvent.clientX, - y: event.nativeEvent.clientY, - } - onNodeContextMenu(node, screenPosition) + // Toggle selection + onNodeSelect(selectedNode?.id === node.id ? null : node) } } } }, - [camera, pointer, raycaster, nodeIndexMap, onNodeContextMenu] + [camera, pointer, raycaster, nodeIndexMap, onNodeSelect, selectedNode] ) return ( diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 870dfaa..947ec06 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -346,6 +346,11 @@ type Point2 = { x: number; y: number } /** * MasterHand - Smash Bros Master Hand / Crazy Hand style * Volumetric filled shapes with soft gradients and ambient occlusion + * + * Z-AXIS: "Reach Through Screen" Paradigm + * - Hand moves TOWARD camera → Virtual hand goes INTO the scene (smaller, recedes) + * - Hand moves AWAY from camera → Virtual hand comes OUT of the scene (larger, approaches) + * This creates the feeling of reaching through a portal into the 3D world. */ function GhostHand({ landmarks, @@ -354,33 +359,40 @@ function GhostHand({ isGhost = false, opacityMultiplier = 1, }: GhostHandProps) { - // "Portal" depth feel: pushing toward the camera/screen should feel like going *into* the graph. - // Current Z (LiDAR): 0.5m (close) .. 3.0m (far) - // Normalized Z (MediaPipe-like): positive (close) .. negative (far) - const wristZ = landmarks[0].z || 0 - const isMeters = Math.abs(wristZ) > 0.5 // Raw LiDAR vs Normalized - // Calculate scale factor based on depth - INVERTED MAPPING - // Physical hand closer to camera -> Virtual hand moves AWAY (smaller) - // Physical hand moves back -> Virtual hand moves CLOSER (larger) + // Detect if Z is in meters (LiDAR: 0.3-3.0m) or normalized (MediaPipe: -0.5 to +0.3) + const isMeters = Math.abs(wristZ) > 0.5 + + // Z-AXIS INVERSION: "Reach Through Screen" Paradigm + // Physical hand closer to camera → Virtual hand appears SMALLER (receding into scene) + // Physical hand farther from camera → Virtual hand appears LARGER (coming out of scene) + // + // This is OPPOSITE of normal perspective where close=large, far=small. + // It creates the illusion that you're reaching THROUGH the screen INTO the 3D world. let scaleFactor = 1.0 + let depthOpacity = 1.0 // Additional opacity based on depth + if (isMeters) { - // LiDAR (meters): 0.3m (close) .. 1.5m (far) - // Close (0.3m) -> Scale 0.6 (distant/small) - // Far (1.0m) -> Scale 1.2 (close/large) - const t = Math.max(0, Math.min(1, (wristZ - 0.3) / 0.7)) // 0 at 0.3m, 1 at 1.0m - scaleFactor = 0.6 + t * 0.8 + // LiDAR in meters: ~0.3m (arm's length) to ~1.5m (extended reach) + // INVERTED: Close (0.3m) → small/faint, Far (1.2m) → large/bright + const normalizedDepth = Math.max(0, Math.min(1, (wristZ - 0.3) / 0.9)) + scaleFactor = 0.4 + normalizedDepth * 1.0 // Range: 0.4 (close) to 1.4 (far) + depthOpacity = 0.5 + normalizedDepth * 0.5 // Range: 0.5 (close/faint) to 1.0 (far/bright) } else { - // MediaPipe (normalized relative to wrist): - // +0.1 (close to camera) -> Scale 0.6 - // -0.2 (far from camera) -> Scale 1.2 - const t = Math.max(0, Math.min(1, (0.1 - wristZ) / 0.3)) // 0 at +0.1, 1 at -0.2 - scaleFactor = 0.6 + t * 0.8 + // MediaPipe normalized: positive Z = closer to camera + // Typical range: +0.15 (close) to -0.25 (far) + // INVERTED: Positive Z (close) → small/faint, Negative Z (far) → large/bright + const normalizedDepth = Math.max(0, Math.min(1, (0.15 - wristZ) / 0.4)) + scaleFactor = 0.4 + normalizedDepth * 1.0 // Range: 0.4 (close) to 1.4 (far) + depthOpacity = 0.5 + normalizedDepth * 0.5 // Range: 0.5 (close/faint) to 1.0 (far/bright) } - const clampedScale = Math.max(0.5, Math.min(1.8, scaleFactor)) + // Apply the depth opacity to the overall opacity multiplier + const effectiveOpacityMultiplier = opacityMultiplier * depthOpacity + + const clampedScale = Math.max(0.3, Math.min(2.0, scaleFactor)) // Un-mirror the X coordinate (selfie-style) and convert to SVG space const toSvg = (lm: { x: number; y: number }) => ({ @@ -389,7 +401,7 @@ function GhostHand({ }) const points = landmarks.map(toSvg) - const gloveOpacity = (isGhost ? 0.5 : 0.85) * opacityMultiplier + const gloveOpacity = (isGhost ? 0.5 : 0.85) * effectiveOpacityMultiplier // Finger width based on scale - fatter fingers for Master Hand look const fingerWidth = 1.8 * clampedScale diff --git a/packages/graph-viewer/src/components/LassoOverlay.tsx b/packages/graph-viewer/src/components/LassoOverlay.tsx index 93dbad3..ac6e2e8 100644 --- a/packages/graph-viewer/src/components/LassoOverlay.tsx +++ b/packages/graph-viewer/src/components/LassoOverlay.tsx @@ -7,7 +7,7 @@ * - Shows selection count badge */ -import { useRef, useCallback, useEffect } from 'react' +import { useRef, useCallback, useEffect, useState } from 'react' interface LassoPoint { x: number @@ -34,13 +34,13 @@ export function LassoOverlay({ onCancelDraw, }: LassoOverlayProps) { const overlayRef = useRef(null) - const isShiftPressedRef = useRef(false) + const [isShiftPressed, setIsShiftPressed] = useState(false) - // Track Shift key state + // Track Shift key state - use state so we can control pointer-events useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') { - isShiftPressedRef.current = true + setIsShiftPressed(true) } if (e.key === 'Escape' && isDrawing) { onCancelDraw() @@ -49,7 +49,7 @@ export function LassoOverlay({ const handleKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') { - isShiftPressedRef.current = false + setIsShiftPressed(false) // If we were drawing when Shift is released, finish the drawing if (isDrawing) { onEndDraw() @@ -68,9 +68,7 @@ export function LassoOverlay({ // Handle mouse events const handleMouseDown = useCallback( (e: React.MouseEvent) => { - // Only start if Shift is held - if (!isShiftPressedRef.current) return - + // Only start if Shift is held (overlay only receives events when shift is pressed) const rect = overlayRef.current?.getBoundingClientRect() if (!rect) return @@ -106,12 +104,15 @@ export function LassoOverlay({ }, '') + ' Z' // Close the path : '' + // Only capture mouse events when shift is pressed or actively drawing + const shouldCaptureMouse = isShiftPressed || isDrawing + return (
void } { - const simulationRef = useRef | null>(null) - const [layoutNodes, setLayoutNodes] = useState([]) - const [isSimulating, setIsSimulating] = useState(false) - const simNodesRef = useRef([]) - - // Initialize simulation when nodes/edges change - useEffect(() => { - if (nodes.length === 0) { - setLayoutNodes([]) - simNodesRef.current = [] - return - } - - // Create simulation nodes with initial positions - const simNodes: SimulationNode[] = nodes.map((node, i) => { - // Check if we have existing position for this node - const existing = simNodesRef.current.find((n) => n.id === node.id) - if (existing) { - return { - ...node, - x: existing.x, - y: existing.y, - z: existing.z, - vx: existing.vx || 0, - vy: existing.vy || 0, - vz: existing.vz || 0, - } - } +// Module-level cache that survives React Strict Mode and HMR +// This is outside React's lifecycle so it persists across component recreation +const layoutCache = { + signature: '', + nodes: [] as SimulationNode[], + simulation: null as ReturnType | null, +} - // Use Fibonacci sphere for initial distribution of new nodes - const phi = Math.acos(1 - (2 * (i + 0.5)) / nodes.length) - const theta = Math.PI * (1 + Math.sqrt(5)) * i - const radius = 50 + (1 - node.importance) * 100 // High importance = center +// Helper to create data signature +function createDataSignature(nodes: GraphNode[]): string { + if (nodes.length === 0) return '' + return `${nodes.length}-${nodes[0]?.id}-${nodes[nodes.length - 1]?.id}` +} +// Helper to run the force simulation (pure function, no React) +function computeLayout( + nodes: GraphNode[], + edges: GraphEdge[], + forceConfig: ForceConfig, + existingNodes: SimulationNode[] +): SimulationNode[] { + // Create simulation nodes with initial positions + const simNodes: SimulationNode[] = nodes.map((node, i) => { + // Check if we have existing position for this node + const existing = existingNodes.find((n) => n.id === node.id) + if (existing) { return { ...node, - x: radius * Math.sin(phi) * Math.cos(theta), - y: radius * Math.sin(phi) * Math.sin(theta), - z: radius * Math.cos(phi), - vx: 0, - vy: 0, - vz: 0, + x: existing.x, + y: existing.y, + z: existing.z, + vx: existing.vx || 0, + vy: existing.vy || 0, + vz: existing.vz || 0, } - }) - - simNodesRef.current = simNodes - - // Create node lookup - const nodeById = new Map(simNodes.map((n) => [n.id, n])) - - // Create links - const links: SimulationLink[] = edges - .filter((e) => nodeById.has(e.source) && nodeById.has(e.target)) - .map((e) => ({ - source: e.source, - target: e.target, - strength: e.strength, - type: e.type, - })) - - // Stop existing simulation - if (simulationRef.current) { - simulationRef.current.stop() } - // Create 3D force simulation - const simulation = forceSimulation(simNodes, 3) - .force( - 'link', - forceLink(links) - .id((d: SimulationNode) => d.id) - .distance((d: SimulationLink) => { - const baseDistance = forceConfig.linkDistance - return baseDistance + (1 - d.strength) * baseDistance - }) - .strength((d: SimulationLink) => d.strength * forceConfig.linkStrength) - ) - .force('charge', forceManyBody().strength(forceConfig.chargeStrength)) - .force('center', forceCenter(0, 0, 0).strength(forceConfig.centerStrength)) - .force( - 'collision', - forceCollide() - .radius((d: SimulationNode) => d.radius * forceConfig.collisionRadius) - .strength(0.7) - ) - .force( - 'radial', - forceRadial( - (d: SimulationNode) => 30 + (1 - d.importance) * 70, - 0, - 0, - 0 - ).strength(0.3) - ) - .alphaDecay(0.02) - .velocityDecay(0.3) - - simulationRef.current = simulation - setIsSimulating(true) - - // Update state on each tick - simulation.on('tick', () => { - setLayoutNodes([...simNodes]) - }) - - simulation.on('end', () => { - setIsSimulating(false) - }) - - // Run simulation for a bit then settle - simulation.alpha(1).restart() - - return () => { - simulation.stop() + // Use Fibonacci sphere for initial distribution of new nodes + const phi = Math.acos(1 - (2 * (i + 0.5)) / nodes.length) + const theta = Math.PI * (1 + Math.sqrt(5)) * i + const radius = 50 + (1 - node.importance) * 100 // High importance = center + + return { + ...node, + x: radius * Math.sin(phi) * Math.cos(theta), + y: radius * Math.sin(phi) * Math.sin(theta), + z: radius * Math.cos(phi), + vx: 0, + vy: 0, + vz: 0, } - }, [nodes, edges]) // Note: forceConfig changes handled separately + }) + + // Create node lookup + const nodeById = new Map(simNodes.map((n) => [n.id, n])) + + // Create links + const links: SimulationLink[] = edges + .filter((e) => nodeById.has(e.source) && nodeById.has(e.target)) + .map((e) => ({ + source: e.source, + target: e.target, + strength: e.strength, + type: e.type, + })) + + // Stop existing simulation + if (layoutCache.simulation) { + layoutCache.simulation.stop() + } + + // Create 3D force simulation + const simulation = forceSimulation(simNodes, 3) + .force( + 'link', + forceLink(links) + .id((d: SimulationNode) => d.id) + .distance((d: SimulationLink) => { + const baseDistance = forceConfig.linkDistance + return baseDistance + (1 - d.strength) * baseDistance + }) + .strength((d: SimulationLink) => d.strength * forceConfig.linkStrength) + ) + .force('charge', forceManyBody().strength(forceConfig.chargeStrength)) + .force('center', forceCenter(0, 0, 0).strength(forceConfig.centerStrength)) + .force( + 'collision', + forceCollide() + .radius((d: SimulationNode) => d.radius * forceConfig.collisionRadius) + .strength(0.7) + ) + .force( + 'radial', + forceRadial( + (d: SimulationNode) => 30 + (1 - d.importance) * 70, + 0, + 0, + 0 + ).strength(0.3) + ) + .alphaDecay(0.02) + .velocityDecay(0.3) + + // Store simulation reference in cache for reheat + layoutCache.simulation = simulation + + // Run simulation synchronously for initial layout + const INITIAL_TICKS = 120 + simulation.alpha(1) + for (let i = 0; i < INITIAL_TICKS; i++) { + simulation.tick() + } + + return simNodes +} - // Update forces when config changes (without resetting positions) - useEffect(() => { - const simulation = simulationRef.current - if (!simulation) return +export function useForceLayout({ + nodes, + edges, + forceConfig = DEFAULT_FORCE_CONFIG, +}: UseForceLayoutOptions): LayoutState & { reheat: () => void } { + const [isSimulating, setIsSimulating] = useState(false) - // Update charge force - const charge = simulation.force('charge') as ReturnType | undefined - if (charge) { - charge.strength(forceConfig.chargeStrength) + // Use useMemo to compute layout synchronously, with module-level caching + // This approach is immune to React Strict Mode double-invocation + const layoutNodes = useMemo(() => { + if (nodes.length === 0) { + layoutCache.signature = '' + layoutCache.nodes = [] + return [] } - // Update center force - const center = simulation.force('center') as ReturnType | undefined - if (center) { - center.strength(forceConfig.centerStrength) - } + const signature = createDataSignature(nodes) - // Update collision force - const collision = simulation.force('collision') as ReturnType | undefined - if (collision) { - collision.radius((d: SimulationNode) => d.radius * forceConfig.collisionRadius) + // Check cache - if signature matches, return cached nodes + if (signature === layoutCache.signature && layoutCache.nodes.length > 0) { + return layoutCache.nodes } - // Update link force - const link = simulation.force('link') as ReturnType | undefined - if (link) { - link - .distance((d: SimulationLink) => { - const baseDistance = forceConfig.linkDistance - return baseDistance + (1 - d.strength) * baseDistance - }) - .strength((d: SimulationLink) => d.strength * forceConfig.linkStrength) - } + // Compute new layout + const computed = computeLayout(nodes, edges, forceConfig, layoutCache.nodes) + + // Update cache + layoutCache.signature = signature + layoutCache.nodes = computed - // Gently reheat to apply changes - simulation.alpha(0.3).restart() - setIsSimulating(true) - }, [forceConfig]) + return computed + }, [nodes, edges, forceConfig]) + // Reheat function uses module-level cache const reheat = useCallback(() => { - if (simulationRef.current) { - simulationRef.current.alpha(0.5).restart() + if (layoutCache.simulation) { + layoutCache.simulation.alpha(0.5).restart() setIsSimulating(true) } }, []) diff --git a/packages/graph-viewer/src/hooks/useHandCursor.ts b/packages/graph-viewer/src/hooks/useHandCursor.ts new file mode 100644 index 0000000..0baca5e --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandCursor.ts @@ -0,0 +1,272 @@ +/** + * useHandCursor - Simplified Hand Cursor Hook + * + * Treats hand tracking like a 3D touchpad: + * - Index fingertip position on screen = cursor position + * - Pinch (thumb+index close) = click + * - No complex arm models, no acquisition delays, no lock states + * + * Coordinate System: + * - Output is in NDC (Normalized Device Coordinates): x,y in range [-1, 1] + * - X: -1 = left edge, +1 = right edge + * - Y: -1 = bottom edge, +1 = top edge + * - Origin (0,0) = screen center + */ + +import { useState, useEffect, useRef } from 'react' +import type { GestureState, HandLandmarks } from './useHandGestures' + +// Pinch detection thresholds (with hysteresis to prevent flickering) +const PINCH_DOWN_THRESHOLD = 0.70 // Requires intentional pinch to activate +const PINCH_UP_THRESHOLD = 0.45 // Lower threshold to release (hysteresis) + +// Screen edge margin - positions within this margin are considered off-screen +const EDGE_MARGIN = 0.05 // 5% from edge + +// Landmark indices +const INDEX_FINGERTIP = 8 +const THUMB_TIP = 4 + +/** + * Pinch state machine phases: + * - idle: No pinch detected + * - down: Just started pinching this frame (rising edge) + * - held: Continuing to pinch + * - up: Just released pinch this frame (falling edge) + */ +export type PinchPhase = 'idle' | 'down' | 'held' | 'up' + +export interface HandCursorState { + /** Whether the cursor is active (hand detected and visible) */ + isActive: boolean + + /** Screen position in NDC [-1, 1], null if no hand */ + screenPosition: { x: number; y: number } | null + + /** Current pinch state machine phase */ + pinchState: PinchPhase + + /** Raw pinch strength 0-1 */ + pinchStrength: number + + /** Which hand is controlling the cursor */ + activeHand: 'left' | 'right' | null + + /** Whether the cursor is near the screen edge */ + isOffScreen: boolean + + /** Depth value for visual feedback (0 = close/faint, 1 = far/bright) */ + normalizedDepth: number +} + +interface UseHandCursorOptions { + /** Enable/disable cursor tracking */ + enabled?: boolean + /** Prefer left or right hand when both are present */ + preferredHand?: 'left' | 'right' +} + +/** + * Calculate pinch strength from thumb-index distance + */ +function calculatePinchStrength(hand: HandLandmarks): number { + const thumb = hand.landmarks[THUMB_TIP] + const index = hand.landmarks[INDEX_FINGERTIP] + + if (!thumb || !index) return 0 + + // Distance in normalized coordinates (0-1 range each) + const dx = thumb.x - index.x + const dy = thumb.y - index.y + const dz = (thumb.z || 0) - (index.z || 0) + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) + + // Map distance to pinch strength + // Pinched: ~0.02-0.04, Extended: ~0.15-0.25 + const minDist = 0.03 // Fully pinched + const maxDist = 0.15 // Fully open + const strength = 1 - Math.max(0, Math.min(1, (distance - minDist) / (maxDist - minDist))) + + return strength +} + +/** + * Convert hand landmark position to NDC screen coordinates + */ +function landmarkToNDC(hand: HandLandmarks): { x: number; y: number } { + const fingertip = hand.landmarks[INDEX_FINGERTIP] + if (!fingertip) return { x: 0, y: 0 } + + // Mirror X for selfie camera (webcam shows mirrored view) + const mirroredX = 1 - fingertip.x + + // Convert to NDC: 0→-1, 0.5→0, 1→+1 + // Flip Y so that top of screen = +1, bottom = -1 + return { + x: mirroredX * 2 - 1, + y: (1 - fingertip.y) * 2 - 1, + } +} + +/** + * Check if position is near screen edge (considered off-screen for UI purposes) + */ +function isNearEdge(ndc: { x: number; y: number }): boolean { + const margin = EDGE_MARGIN * 2 // Convert to NDC range (-1 to 1) + return ( + Math.abs(ndc.x) > 1 - margin || + Math.abs(ndc.y) > 1 - margin + ) +} + +/** + * Calculate normalized depth for visual feedback + * Returns 0-1 where 0 = close to camera, 1 = far from camera + */ +function calculateNormalizedDepth(hand: HandLandmarks): number { + const wristZ = hand.landmarks[0]?.z || 0 + + // Detect if Z is in meters (LiDAR: 0.3-3.0m) or normalized (MediaPipe: -0.5 to +0.3) + const isMeters = Math.abs(wristZ) > 0.5 + + if (isMeters) { + // LiDAR in meters: ~0.3m (close) to ~1.2m (far) + return Math.max(0, Math.min(1, (wristZ - 0.3) / 0.9)) + } else { + // MediaPipe: positive Z = closer, negative Z = farther + // Range typically +0.15 (close) to -0.25 (far) + return Math.max(0, Math.min(1, (0.15 - wristZ) / 0.4)) + } +} + +/** + * useHandCursor - Simplified hand cursor tracking + * + * @param gestureState - Current gesture state from useHandGestures + * @param options - Configuration options + * @returns HandCursorState with cursor position and pinch state + */ +export function useHandCursor( + gestureState: GestureState, + options: UseHandCursorOptions = {} +): HandCursorState { + const { enabled = true, preferredHand = 'right' } = options + + // Track previous pinch state for state machine transitions + const prevPinchStrengthRef = useRef(0) + const pinchPhaseRef = useRef('idle') + + // Current cursor state + const [cursorState, setCursorState] = useState({ + isActive: false, + screenPosition: null, + pinchState: 'idle', + pinchStrength: 0, + activeHand: null, + isOffScreen: false, + normalizedDepth: 0.5, + }) + + // Update cursor state based on gesture state + useEffect(() => { + if (!enabled || !gestureState.isTracking) { + if (cursorState.isActive) { + setCursorState({ + isActive: false, + screenPosition: null, + pinchState: 'idle', + pinchStrength: 0, + activeHand: null, + isOffScreen: false, + normalizedDepth: 0.5, + }) + pinchPhaseRef.current = 'idle' + } + return + } + + // Select active hand (prefer right, fall back to left, or use what's available) + let activeHand: HandLandmarks | null = null + let activeHandSide: 'left' | 'right' | null = null + + if (preferredHand === 'right') { + if (gestureState.rightHand) { + activeHand = gestureState.rightHand + activeHandSide = 'right' + } else if (gestureState.leftHand) { + activeHand = gestureState.leftHand + activeHandSide = 'left' + } + } else { + if (gestureState.leftHand) { + activeHand = gestureState.leftHand + activeHandSide = 'left' + } else if (gestureState.rightHand) { + activeHand = gestureState.rightHand + activeHandSide = 'right' + } + } + + if (!activeHand) { + setCursorState({ + isActive: false, + screenPosition: null, + pinchState: 'idle', + pinchStrength: 0, + activeHand: null, + isOffScreen: false, + normalizedDepth: 0.5, + }) + pinchPhaseRef.current = 'idle' + return + } + + // Calculate cursor position and pinch strength + const screenPosition = landmarkToNDC(activeHand) + const pinchStrength = calculatePinchStrength(activeHand) + const isOffScreen = isNearEdge(screenPosition) + const normalizedDepth = calculateNormalizedDepth(activeHand) + + // Pinch state machine with hysteresis + const prevPhase = pinchPhaseRef.current + let newPhase: PinchPhase + + switch (prevPhase) { + case 'idle': + // Transition to 'down' when pinch crosses threshold + newPhase = pinchStrength >= PINCH_DOWN_THRESHOLD ? 'down' : 'idle' + break + case 'down': + // Always transition to 'held' next frame (down is a single-frame event) + newPhase = 'held' + break + case 'held': + // Transition to 'up' when pinch drops below release threshold + newPhase = pinchStrength < PINCH_UP_THRESHOLD ? 'up' : 'held' + break + case 'up': + // Always transition to 'idle' next frame (up is a single-frame event) + newPhase = 'idle' + break + default: + newPhase = 'idle' + } + + pinchPhaseRef.current = newPhase + prevPinchStrengthRef.current = pinchStrength + + setCursorState({ + isActive: true, + screenPosition, + pinchState: newPhase, + pinchStrength, + activeHand: activeHandSide, + isOffScreen, + normalizedDepth, + }) + }, [enabled, gestureState, preferredHand, cursorState.isActive]) + + return cursorState +} + +export default useHandCursor From 0c77a6b571f3b1367a2793bb6bee96396fa2a3f4 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 24 Dec 2025 21:37:53 +0100 Subject: [PATCH 37/47] fix: Remove advanced hand interaction and laser features Simplifies hand gesture controls to only support fist grab for panning the graph. Removes hand cursor, laser pointer, expanded node selection, and related overlays and hooks. Updates overlays to provide only basic visual hand feedback without lasers or node selection. --- packages/graph-viewer/src/App.tsx | 2 - .../src/components/GraphCanvas.tsx | 317 +-------------- .../src/components/Hand2DOverlay.tsx | 338 +--------------- .../src/components/HandSkeletonOverlay.tsx | 238 ----------- .../src/components/LaserPointer.tsx | 257 ------------ .../graph-viewer/src/hooks/useHandCursor.ts | 272 ------------- .../src/hooks/useHandInteraction.ts | 268 ------------- .../src/hooks/useStablePointerRay.ts | 378 ------------------ 8 files changed, 23 insertions(+), 2047 deletions(-) delete mode 100644 packages/graph-viewer/src/components/HandSkeletonOverlay.tsx delete mode 100644 packages/graph-viewer/src/components/LaserPointer.tsx delete mode 100644 packages/graph-viewer/src/hooks/useHandCursor.ts delete mode 100644 packages/graph-viewer/src/hooks/useHandInteraction.ts delete mode 100644 packages/graph-viewer/src/hooks/useStablePointerRay.ts diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 9e343ca..0b6701e 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -763,8 +763,6 @@ export default function App() { {/* Gesture Debug Overlay */} diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 4949f22..2e940e5 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -9,12 +9,9 @@ * - Optional post-processing (performance mode toggle) * - Single useFrame callback for all animations * - * Hand interaction features: - * - Stable pointer ray with arm model + One Euro Filter - * - Accurate ray-sphere intersection for node selection - * - Pinch-to-select with expansion animation - * - Pull/push gestures for Z manipulation - * - Two-hand rotation and zoom + * Interaction model (simplified): + * - Mouse: Click nodes to select, OrbitControls for navigation + * - Hand gestures: Fist grab to pan the graph */ import { useRef, useMemo, useState, useCallback, useEffect } from 'react' @@ -25,10 +22,7 @@ import * as THREE from 'three' import { useForceLayout } from '../hooks/useForceLayout' import { useHandGestures, GestureState } from '../hooks/useHandGestures' import { useIPhoneHandTracking } from '../hooks/useIPhoneHandTracking' -import { useHandInteraction } from '../hooks/useHandInteraction' import { useHandLockAndGrab } from '../hooks/useHandLockAndGrab' -import { useHandCursor } from '../hooks/useHandCursor' -import { ExpandedNodeView } from './ExpandedNodeView' import type { GraphNode, GraphEdge, @@ -47,7 +41,6 @@ import { getEdgeStyle } from '../lib/edgeStyles' import { EdgeParticles } from './EdgeParticles' import { MiniMap } from './MiniMap' import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' -import { findNodeHit, type NodeSphere, type NodeHit } from '../hooks/useStablePointerRay' // Get iPhone WebSocket URL from URL params or default function useIPhoneUrl() { @@ -69,10 +62,6 @@ const SPHERE_SEGMENTS = 12 // Reduced from 32 - good enough for small spheres const LABEL_DISTANCE_THRESHOLD = 80 // Only show labels for nodes within this distance const MAX_VISIBLE_LABELS = 10 // Maximum labels to show at once (for LOD) -// Gesture control constants -const GESTURE_DEADZONE = 0.005 // Ignore tiny movements -const MAX_TRANSLATE_SPEED = 3 // Cap cloud translation per frame - interface GraphCanvasProps { nodes: GraphNode[] edges: GraphEdge[] @@ -521,205 +510,15 @@ function Scene({ } }) - // Expanded node state (for the bloom animation) - const [expandedNodeId, setExpandedNodeId] = useState(null) - const [hitPoint, setHitPoint] = useState<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }) - const [isExpanding, setIsExpanding] = useState(false) - - // Hand interaction (stable rays) - used for future two-hand gestures and internal metrics - const { interactionState, processGestures } = useHandInteraction({ - nodes: layoutNodes, - enableSelection: false, - onNodeSelect: (nodeId) => { - if (nodeId) { - // Find the node and trigger expansion - const node = layoutNodes.find(n => n.id === nodeId) - if (node) { - setExpandedNodeId(nodeId) - setHitPoint({ - x: interactionState.hoveredNode?.point.x ?? node.x ?? 0, - y: interactionState.hoveredNode?.point.y ?? node.y ?? 0, - z: interactionState.hoveredNode?.point.z ?? node.z ?? 0, - }) - setIsExpanding(true) - } - onNodeSelect(node ?? null) - } else { - setExpandedNodeId(null) - setIsExpanding(false) - onNodeSelect(null) - } - }, - // We'll drive hover from the explicit pointing ray (below) - onNodeHover: undefined, - }) - - // Process gestures each frame - useEffect(() => { - if (gestureControlEnabled && gestureState.isTracking) { - processGestures(gestureState) - } - }, [gestureControlEnabled, gestureState, processGestures]) - - // New UI: open-palm acquire/lock + fist grab controls (single-hand for now) + // Hand grab controls - fist to pan the graph const { lock: handLock, deltas: grabDeltas } = useHandLockAndGrab(gestureState, gestureControlEnabled) - // Simplified hand cursor (replaces complex pointing/pinch logic) - const cursorState = useHandCursor(gestureState, { enabled: gestureControlEnabled }) - - // Track cursor hit state for rendering - const [cursorHit, setCursorHit] = useState(null) - const [cursorWorldPoint, setCursorWorldPoint] = useState<{ x: number; y: number; z: number } | null>(null) - const raycasterRef = useRef(new THREE.Raycaster()) - - // Helper to check if hand is currently grabbing - const isGrabbing = handLock.mode === 'locked' && handLock.grabbed - - const nodeSpheres: NodeSphere[] = useMemo(() => { - return layoutNodes.map((n) => ({ - id: n.id, - x: n.x ?? 0, - y: n.y ?? 0, - z: n.z ?? 0, - radius: (n.radius ?? 1) * 1.5, - })) - }, [layoutNodes]) - - // Helper: select a node by id and animate expansion - const selectNodeById = useCallback( - (nodeId: string | null, hit?: { x: number; y: number; z: number }) => { - if (nodeId) { - const node = layoutNodes.find((n) => n.id === nodeId) ?? null - if (node) { - setExpandedNodeId(nodeId) - setHitPoint({ - x: hit?.x ?? node.x ?? 0, - y: hit?.y ?? node.y ?? 0, - z: hit?.z ?? node.z ?? 0, - }) - setIsExpanding(true) - } - onNodeSelect(node) - } else { - setExpandedNodeId(null) - setIsExpanding(false) - onNodeSelect(null) - } - }, - [layoutNodes, onNodeSelect] - ) - // Create node lookup for edges const nodeById = useMemo( () => new Map(layoutNodes.map((n) => [n.id, n])), [layoutNodes] ) - // Simplified cursor-based pointing (index fingertip = cursor, pinch = click) - // Uses useHandCursor hook for immediate, no-lock cursor tracking - useEffect(() => { - if (!gestureControlEnabled || !cursorState.isActive || !cursorState.screenPosition) { - setCursorHit(null) - setCursorWorldPoint(null) - // Only clear hover if we're in cursor mode and lost tracking - if (gestureControlEnabled && !cursorState.isActive) { - onNodeHover(null) - } - return - } - - // Skip cursor updates when grabbing (fist gesture controls camera, not cursor) - if (isGrabbing) { - setCursorHit(null) - setCursorWorldPoint(null) - return - } - - const { x: ndcX, y: ndcY } = cursorState.screenPosition - - // Build ray from camera through NDC point - const raycaster = raycasterRef.current - raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera) - - // Hit test against node spheres using findNodeHit (works in group local space) - const group = groupRef.current - let hit: NodeHit | null = null - - if (group) { - const inv = group.matrixWorld.clone().invert() - const localOrigin = raycaster.ray.origin.clone().applyMatrix4(inv) - const localDir = raycaster.ray.direction.clone().transformDirection(inv) - hit = findNodeHit( - { - origin: { x: localOrigin.x, y: localOrigin.y, z: localOrigin.z }, - direction: { x: localDir.x, y: localDir.y, z: localDir.z }, - }, - nodeSpheres, - 4000 - ) - } - - // Snap to node center for deterministic selection - const snapNode = hit ? (nodeById.get(hit.nodeId) ?? null) : null - const snappedHit: NodeHit | null = - hit && snapNode - ? { - ...hit, - point: { - x: snapNode.x ?? hit.point.x, - y: snapNode.y ?? hit.point.y, - z: snapNode.z ?? hit.point.z, - }, - } - : hit - - setCursorHit(snappedHit) - - // Calculate world-space cursor position for rendering - let worldPoint: THREE.Vector3 | null = null - if (group && snappedHit) { - worldPoint = new THREE.Vector3( - snappedHit.point.x, - snappedHit.point.y, - snappedHit.point.z - ).applyMatrix4(group.matrixWorld) - } else if (group) { - // No hit - intersect with plane at graph center - const center = group.getWorldPosition(new THREE.Vector3()) - const normal = camera.getWorldDirection(new THREE.Vector3()).normalize() - const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, center) - worldPoint = raycaster.ray.intersectPlane(plane, new THREE.Vector3()) || null - } - if (!worldPoint) { - worldPoint = raycaster.ray.origin.clone().add(raycaster.ray.direction.clone().multiplyScalar(200)) - } - setCursorWorldPoint({ x: worldPoint.x, y: worldPoint.y, z: worldPoint.z }) - - // Update hover state - const hoverNode = snappedHit ? (nodeById.get(snappedHit.nodeId) ?? null) : null - onNodeHover(hoverNode as GraphNode | null) - }, [ - gestureControlEnabled, - cursorState.isActive, - cursorState.screenPosition?.x, - cursorState.screenPosition?.y, - isGrabbing, - camera, - nodeSpheres, - nodeById, - onNodeHover, - ]) - - // Pinch-to-select: trigger on pinch DOWN (immediate response) - useEffect(() => { - if (!gestureControlEnabled) return - - // Select on pinch down (the moment pinch is detected) - if (cursorState.pinchState === 'down' && cursorHit) { - selectNodeById(cursorHit.nodeId, cursorHit.point) - } - }, [gestureControlEnabled, cursorState.pinchState, cursorHit, selectNodeById]) - // Filter nodes based on search const searchLower = searchTerm.toLowerCase() const matchingIds = useMemo(() => { @@ -764,57 +563,23 @@ function Scene({ return layoutNodes.find(n => n.id === selectedNode.id) ?? null }, [selectedNode, layoutNodes]) - // Get expanded node and its connections - const expandedNode = useMemo(() => { - if (!expandedNodeId) return null - return layoutNodes.find(n => n.id === expandedNodeId) ?? null - }, [expandedNodeId, layoutNodes]) - - const connectedToExpanded = useMemo(() => { - if (!expandedNodeId) return [] - const connectedNodeIds = new Set() - edges.forEach(e => { - if (e.source === expandedNodeId) connectedNodeIds.add(e.target) - if (e.target === expandedNodeId) connectedNodeIds.add(e.source) - }) - return layoutNodes.filter(n => connectedNodeIds.has(n.id)) - }, [expandedNodeId, edges, layoutNodes]) - // Stop auto-rotate on user interaction const handleInteractionStart = useCallback(() => { setAutoRotate(false) }, []) - // Close expanded node - const handleCloseExpanded = useCallback(() => { - setExpandedNodeId(null) - setIsExpanding(false) - onNodeSelect(null) - }, [onNodeSelect]) - // Track world position at grab start for displacement-based movement const grabStartPosRef = useRef({ x: 0, y: 0, z: 0 }) - // Smoothed pan for non-grab controls only - const smoothedPanZRef = useRef(0) - - // Clamp helper - const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) - - // Apply gesture controls to move the CLOUD (not camera) - // Grab = physically grab the world and drag it around (displacement-based, not velocity) + // Apply grab controls - fist to pan the graph useEffect(() => { if (!gestureControlEnabled || !groupRef.current) return if (!gestureState.isTracking) return const group = groupRef.current + const isGrabbing = handLock.mode === 'locked' && handLock.grabbed - // Use the new interaction state for cloud manipulation - const { rotationDelta, zoomDelta, dragDeltaZ, isDragging } = interactionState - - const usingGrabControls = handLock.mode === 'locked' && handLock.grabbed - - if (usingGrabControls) { + if (isGrabbing) { // On first frame of grab, capture current world position if (grabDeltas.grabStart) { grabStartPosRef.current = { @@ -822,37 +587,16 @@ function Scene({ y: group.position.y, z: group.position.z, } - return // Don't apply deltas on first frame + return } - // DISPLACEMENT-BASED: Set position relative to grab start, not add velocity - // panX/panY/panZ are how much to offset from the grab start position + // DISPLACEMENT-BASED: Set position relative to grab start const startPos = grabStartPosRef.current - group.position.x = startPos.x + grabDeltas.panX group.position.y = startPos.y + grabDeltas.panY group.position.z = startPos.z + grabDeltas.panZ - } else { - // Apply zoom (two-hand spread/pinch) - if (Math.abs(zoomDelta) > GESTURE_DEADZONE) { - group.position.z += zoomDelta * 0.5 - } - - // Apply rotation (two-hand rotation) - if (Math.abs(rotationDelta.x) > GESTURE_DEADZONE) { - group.rotation.z += rotationDelta.x - } - - // Apply Z drag (single hand push/pull when not selecting a node) - if (!isDragging && Math.abs(dragDeltaZ) > GESTURE_DEADZONE) { - smoothedPanZRef.current += (dragDeltaZ - smoothedPanZRef.current) * 0.2 - const clamped = clamp(smoothedPanZRef.current, -MAX_TRANSLATE_SPEED, MAX_TRANSLATE_SPEED) - group.position.z += clamped - } else { - smoothedPanZRef.current *= 0.9 - } } - }, [gestureControlEnabled, gestureState, interactionState, handLock, grabDeltas]) + }, [gestureControlEnabled, gestureState, handLock, grabDeltas]) return ( <> @@ -961,49 +705,8 @@ function Scene({ /> )} - {/* Expanded Node View - shows when a node is selected via hand */} - {expandedNode && ( - - )} - {/* Simplified hand cursor - just a dot at cursor position (no laser) */} - {gestureControlEnabled && - cursorState.isActive && - !isGrabbing && - cursorWorldPoint && ( - - {/* Size: larger when hovering or pinching */} - 0.7 - ? (cursorHit ? 1.0 : 0.8) // Pinching: large - : cursorHit - ? 0.6 // Hovering: medium - : 0.4, // Idle: small - 16, 16 - ]} /> - {/* Color: blue (idle) → gold (hover) → white (pinch) */} - 0.7 - ? '#ffffff' // Pinching: white - : cursorHit - ? '#fbbf24' // Hovering: gold - : '#60a5fa' // Idle: blue - } - transparent - opacity={cursorState.pinchStrength > 0.7 ? 1.0 : 0.85} - /> - - )} - {/* Post-processing effects - conditional based on performance mode */} {!performanceMode && ( diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 947ec06..dd33e89 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -1,19 +1,16 @@ /** * Hand 2D Overlay * - * Renders hands as a 2D overlay on top of the canvas with: - * - Ghost 3D hand effect (translucent, glowing) - * - Smoothing/interpolation (ghost persists when hand disappears) - * - Accurate laser beams using stable pointer ray with arm model - * - Hit indicator when pointing at a node - * - Pinch grip indicator (lights up when gripped) - * - Support for two-hand manipulation + * Renders hands as a 2D SVG overlay with: + * - Ghost 3D hand effect (translucent, glowing Master Hand style) + * - Smoothing/interpolation (ghost persists briefly when hand disappears) + * - Depth-aware scaling ("reach through screen" paradigm) + * + * SIMPLIFIED: No lasers, no center target, just visual hand feedback. */ import { useState, useEffect, useRef } from 'react' -import type { GestureState, PinchRay } from '../hooks/useHandGestures' -import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' -import type { HandLockState } from '../hooks/useHandLockAndGrab' +import type { GestureState } from '../hooks/useHandGestures' // Fingertip indices const FINGERTIPS = [4, 8, 12, 16, 20] @@ -26,10 +23,6 @@ const SMOOTHING_FACTOR = 0.2 // Lower = smoother but laggier const GHOST_FADE_DURATION = 500 // ms to fade out ghost hand const GHOST_PERSIST_DURATION = 300 // ms to keep ghost before fading -// Laser configuration -const LASER_CENTER_BIAS = 0.7 // How strongly laser aims at center (0 = follow hand, 1 = always center) -const LASER_DEVIATION_SCALE = 0.3 // How much hand position affects laser direction - interface SmoothedHand { landmarks: { x: number; y: number; z: number }[] lastSeen: number @@ -40,49 +33,17 @@ interface SmoothedHand { interface Hand2DOverlayProps { gestureState: GestureState enabled?: boolean - showLaser?: boolean - /** Stable left ray from arm model + One Euro Filter */ - leftStableRay?: StableRay | null - /** Stable right ray from arm model + One Euro Filter */ - rightStableRay?: StableRay | null - /** Current node hit (if any) */ - hoveredNode?: NodeHit | null - /** Optional lock/grab state for nicer visuals */ - handLock?: HandLockState } export function Hand2DOverlay({ gestureState, enabled = true, - showLaser = true, - leftStableRay, - rightStableRay, - hoveredNode, - handLock, }: Hand2DOverlayProps) { // Track smoothed hand positions with ghost effect const [leftSmoothed, setLeftSmoothed] = useState(null) const [rightSmoothed, setRightSmoothed] = useState(null) const animationRef = useRef() - // Track activation flash - const [activationFlash, setActivationFlash] = useState(false) - const prevLockModeRef = useRef('idle') - - // Flash when transitioning to locked - useEffect(() => { - const prevMode = prevLockModeRef.current - const currentMode = handLock?.mode ?? 'idle' - - if (prevMode !== 'locked' && currentMode === 'locked') { - // Just became locked - trigger flash - setActivationFlash(true) - setTimeout(() => setActivationFlash(false), 400) - } - - prevLockModeRef.current = currentMode - }, [handLock?.mode]) - // Smoothing and ghost effect useEffect(() => { if (!enabled) return @@ -174,49 +135,11 @@ export function Hand2DOverlay({ if (!enabled || !gestureState.isTracking) return null - // Visual state: VERY faint until locked, then solid - // Hands should be almost invisible until activation gesture is performed - const lockMode = handLock?.mode - const isLocked = lockMode === 'locked' - const isGrabbing = isLocked && ((handLock as any).grabbed as boolean) - const isPinching = isLocked && (((handLock as any).metrics?.pinch as number) ?? 0) > 0.75 - const isActive = isGrabbing || isPinching - - // Opacity: very faint when not locked, full when locked and active - const opacityMultiplier = !handLock - ? 0.15 // No lock state provided - very faint - : lockMode === 'idle' - ? 0.12 // Idle - barely visible ghost - : lockMode === 'candidate' - ? 0.35 // Acquiring - starting to show - : isLocked - ? (isActive ? 1.2 : 0.85) // Locked - full visibility; brighter when active - : 0.12 // Fallback - very faint - - // Check if pointing (for laser visibility) - const isPointing = lockMode === 'locked' && ((handLock as any).metrics?.point as number ?? 0) > 0.5 - - // Check if both hands are gripping (for two-hand manipulation) - const leftGripping = gestureState.leftPinchRay?.isValid - const rightGripping = gestureState.rightPinchRay?.isValid - const bothGripping = leftGripping && rightGripping - - // Laser visibility: show while aiming (pointing) or while pinching - const leftLaserActive = isPointing || (gestureState.leftPinchRay?.strength ?? 0) > 0.3 - const rightLaserActive = isPointing || (gestureState.rightPinchRay?.strength ?? 0) > 0.3 + // Simple opacity - always visible when tracking + const opacityMultiplier = 0.85 return (
- {/* Activation flash overlay */} - {activationFlash && ( -
- )} )} - - {/* Connection line between hands when both gripping */} - {bothGripping && gestureState.leftPinchRay && gestureState.rightPinchRay && ( - - )} - - {/* Left laser */} - {showLaser && leftLaserActive && leftStableRay?.screenHit && gestureState.leftPinchRay && ( - = (rightStableRay?.confidence ?? 0)} - /> - )} - - {/* Right laser */} - {showLaser && rightLaserActive && rightStableRay?.screenHit && gestureState.rightPinchRay && ( - (leftStableRay?.confidence ?? 0)} - /> - )} - - {/* Center nexus indicator when gripping */} - {(leftGripping || rightGripping) && ( - - - - - )}
) @@ -381,10 +258,10 @@ function GhostHand({ scaleFactor = 0.4 + normalizedDepth * 1.0 // Range: 0.4 (close) to 1.4 (far) depthOpacity = 0.5 + normalizedDepth * 0.5 // Range: 0.5 (close/faint) to 1.0 (far/bright) } else { - // MediaPipe normalized: positive Z = closer to camera - // Typical range: +0.15 (close) to -0.25 (far) - // INVERTED: Positive Z (close) → small/faint, Negative Z (far) → large/bright - const normalizedDepth = Math.max(0, Math.min(1, (0.15 - wristZ) / 0.4)) + // MediaPipe normalized: positive Z = FARTHER from camera, negative Z = CLOSER + // Typical range: -0.25 (close) to +0.15 (far) + // "Reach through screen": Close → small/faint, Far → large/bright + const normalizedDepth = Math.max(0, Math.min(1, (wristZ + 0.25) / 0.4)) scaleFactor = 0.4 + normalizedDepth * 1.0 // Range: 0.4 (close) to 1.4 (far) depthOpacity = 0.5 + normalizedDepth * 0.5 // Range: 0.5 (close/faint) to 1.0 (far/bright) } @@ -624,193 +501,4 @@ function GhostHand({ ) } -interface LaserBeamProps { - ray: PinchRay - stableRay?: StableRay | null - color: string - isGripped: boolean - hasHit?: boolean -} - -function LaserBeam({ ray, stableRay, color, isGripped, hasHit = false }: LaserBeamProps) { - // Use stable ray screen hit if available, otherwise fall back to basic calculation - let originX: number, originY: number, endX: number, endY: number - - if (stableRay?.screenHit) { - // Use the stable ray (arm model + One Euro Filter) - // Un-mirror the X coordinate (webcam is mirrored) - originX = (1 - stableRay.pinchPoint.x) * 100 - originY = stableRay.pinchPoint.y * 100 - - // End point from ray intersection with screen plane - endX = (1 - stableRay.screenHit.x) * 100 - endY = stableRay.screenHit.y * 100 - - // Clamp to viewport - endX = Math.max(0, Math.min(100, endX)) - endY = Math.max(0, Math.min(100, endY)) - } else { - // Fallback: original basic ray calculation - originX = (1 - ray.origin.x) * 100 - originY = ray.origin.y * 100 - - const centerX = 50 - const centerY = 50 - const handDeviationX = (originX - 50) * LASER_DEVIATION_SCALE - const handDeviationY = (originY - 50) * LASER_DEVIATION_SCALE - const targetX = centerX - handDeviationX * (1 - LASER_CENTER_BIAS) - const targetY = centerY - handDeviationY * (1 - LASER_CENTER_BIAS) - - const toCenterX = targetX - originX - const toCenterY = targetY - originY - const dist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) - const normX = dist > 0 ? toCenterX / dist : 0 - const normY = dist > 0 ? toCenterY / dist : 0 - const laserLength = dist - - endX = originX + normX * laserLength - endY = originY + normY * laserLength - } - - // Visual properties - intensify based on state - const pinchStrength = stableRay?.pinchStrength ?? ray.strength - const baseStrokeWidth = 0.2 + pinchStrength * 0.3 - const strokeWidth = isGripped ? baseStrokeWidth * 2.5 : hasHit ? baseStrokeWidth * 1.5 : baseStrokeWidth - const baseOpacity = 0.3 + pinchStrength * 0.4 - const opacity = isGripped ? Math.min(1, baseOpacity * 1.8) : hasHit ? baseOpacity * 1.3 : baseOpacity - const glowRadius = isGripped ? 2 + pinchStrength * 2 : hasHit ? 1.5 + pinchStrength : 0.8 + pinchStrength * 0.8 - - // Color changes based on state - const activeColor = isGripped ? '#ffffff' : hasHit ? '#fbbf24' : color // Golden when hitting - const confidence = stableRay?.confidence ?? 0.8 - - return ( - - {/* Outer glow when gripped */} - {isGripped && ( - - )} - - {/* Laser glow (wider, more transparent) */} - - - {/* Main laser beam */} - - - {/* Origin glow sphere */} - - - - {/* Hit indicator - pulsing crosshair when pointing at a node */} - {hasHit && ( - - {/* Outer ring */} - - {/* Inner target */} - - {/* Crosshair lines */} - - - - - - )} - - {/* Impact point at center - "warm spot" where laser hits the nexus */} - - {isGripped && ( - <> - - - - )} - - {/* Pulsing ring at origin when gripped */} - {isGripped && ( - - )} - - ) -} - export default Hand2DOverlay diff --git a/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx b/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx deleted file mode 100644 index 7961f10..0000000 --- a/packages/graph-viewer/src/components/HandSkeletonOverlay.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Hand Skeleton Overlay - * - * Renders 3D wireframe hands overlaid on the graph visualization. - * Uses React Three Fiber to render hand landmarks as glowing lines. - */ - -import { useMemo } from 'react' -import { Line } from '@react-three/drei' -import type { HandLandmarks, GestureState, PinchRay } from '../hooks/useHandGestures' - -// Hand skeleton connections (pairs of landmark indices) -const HAND_CONNECTIONS = [ - // Thumb - [0, 1], [1, 2], [2, 3], [3, 4], - // Index finger - [0, 5], [5, 6], [6, 7], [7, 8], - // Middle finger - [0, 9], [9, 10], [10, 11], [11, 12], - // Ring finger - [0, 13], [13, 14], [14, 15], [15, 16], - // Pinky - [0, 17], [17, 18], [18, 19], [19, 20], - // Palm - [5, 9], [9, 13], [13, 17], [0, 17], -] - -interface HandSkeletonProps { - hand: HandLandmarks - color: string - opacity?: number - scale?: number -} - -// Convert MediaPipe normalized coords (0-1) to Three.js world coords -function landmarkToWorld( - landmark: { x: number; y: number; z: number }, - scale: number = 10 -): [number, number, number] { - // MediaPipe: x=0-1 (left-right), y=0-1 (top-bottom), z=depth - // Three.js: x=-5 to 5, y=-5 to 5, z=depth - return [ - (landmark.x - 0.5) * scale, - -(landmark.y - 0.5) * scale, // Flip Y - -landmark.z * scale * 2, // Z comes toward camera - ] -} - -function HandSkeleton({ hand, color, opacity = 0.8, scale = 10 }: HandSkeletonProps) { - const lines = useMemo(() => { - return HAND_CONNECTIONS.map(([i, j], idx) => { - const start = landmarkToWorld(hand.landmarks[i], scale) - const end = landmarkToWorld(hand.landmarks[j], scale) - return { start, end, key: idx } - }) - }, [hand.landmarks, scale]) - - const jointPositions = useMemo(() => { - return hand.landmarks.map((lm, idx) => ({ - position: landmarkToWorld(lm, scale), - key: idx, - // Fingertips get larger spheres - isFingertip: [4, 8, 12, 16, 20].includes(idx), - })) - }, [hand.landmarks, scale]) - - return ( - - {/* Skeleton lines */} - {lines.map(({ start, end, key }) => ( - - ))} - - {/* Joint spheres */} - {jointPositions.map(({ position, key, isFingertip }) => ( - - - - - ))} - - ) -} - -// Convert pinch ray origin (normalized 0-1 coords) to Three.js world coords -function pinchRayToWorld( - ray: PinchRay, - scale: number = 10 -): { origin: [number, number, number]; end: [number, number, number] } { - // Origin in 3D space - const origin: [number, number, number] = [ - (ray.origin.x - 0.5) * scale, - -(ray.origin.y - 0.5) * scale, // Flip Y - -ray.origin.z * scale * 2, - ] - - // Ray extends in the direction, scaled by ray length - const rayLength = 100 // How far the laser extends - const end: [number, number, number] = [ - origin[0] + ray.direction.x * rayLength, - origin[1] - ray.direction.y * rayLength, // Flip Y for direction too - origin[2] - ray.direction.z * rayLength, - ] - - return { origin, end } -} - -interface PinchRayBeamProps { - ray: PinchRay - color: string - scale?: number -} - -function PinchRayBeam({ ray, color, scale = 10 }: PinchRayBeamProps) { - const { origin, end } = useMemo(() => pinchRayToWorld(ray, scale), [ray, scale]) - - // Calculate visual properties based on pinch strength - const lineWidth = 1 + ray.strength * 3 // Thicker when pinching harder - const opacity = 0.3 + ray.strength * 0.5 // More visible when pinching - - // Glow sphere size at origin - const sphereSize = 0.1 + ray.strength * 0.15 - - return ( - - {/* Main laser beam */} - - - {/* Origin glow sphere (where thumb meets index) */} - - - - - - {/* Secondary glow ring when pinch is active */} - {ray.isValid && ( - - - - - )} - - ) -} - -interface GestureIndicatorProps { - gestureState: GestureState -} - -function GestureIndicator({ gestureState }: GestureIndicatorProps) { - const { handsDetected, zoomDelta, leftPinchRay, rightPinchRay } = gestureState - - return ( - - {/* Left hand pinch ray - cyan */} - {leftPinchRay && leftPinchRay.strength > 0.3 && ( - - )} - - {/* Right hand pinch ray - magenta */} - {rightPinchRay && rightPinchRay.strength > 0.3 && ( - - )} - - {/* Two-hand zoom indicator */} - {handsDetected === 2 && Math.abs(zoomDelta) > 0.01 && ( - - - 0 ? '#4ecdc4' : '#ff6b6b'} - transparent - opacity={0.6} - /> - - )} - - ) -} - -interface HandSkeletonOverlayProps { - gestureState: GestureState - enabled?: boolean -} - -export function HandSkeletonOverlay({ gestureState, enabled = true }: HandSkeletonOverlayProps) { - if (!enabled || !gestureState.isTracking) return null - - return ( - - {/* Left hand - cyan */} - {gestureState.leftHand && ( - - )} - - {/* Right hand - magenta */} - {gestureState.rightHand && ( - - )} - - {/* Gesture feedback */} - - - ) -} - -export default HandSkeletonOverlay diff --git a/packages/graph-viewer/src/components/LaserPointer.tsx b/packages/graph-viewer/src/components/LaserPointer.tsx deleted file mode 100644 index c79c209..0000000 --- a/packages/graph-viewer/src/components/LaserPointer.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Laser Pointer Component - * - * Renders a beautiful, accurate laser beam from the hand to the target. - * Features: - * - Gradient beam with glow effects - * - Hit indicator when pointing at a node - * - Ripple effect on activation - * - Arm model visualization (optional debug) - */ - -import { useRef, useMemo } from 'react' -import { useFrame } from '@react-three/fiber' -import { Line } from '@react-three/drei' -import * as THREE from 'three' -import type { StableRay, NodeHit } from '../hooks/useStablePointerRay' - -interface LaserPointerProps { - ray: StableRay - hit: NodeHit | null - color: string - showArmModel?: boolean -} - -export function LaserPointer({ ray, hit, color, showArmModel = false }: LaserPointerProps) { - const glowRef = useRef(null) - const hitGlowRef = useRef(null) - const rippleRef = useRef(null) - - // Laser beam points - const beamPoints = useMemo(() => { - const start: [number, number, number] = [ - ray.origin.x, - ray.origin.y, - ray.origin.z, - ] - - // End point: either hit point or extend ray into distance - const maxDistance = 200 - const distance = hit ? hit.distance : maxDistance - const end: [number, number, number] = [ - ray.origin.x + ray.direction.x * distance, - ray.origin.y + ray.direction.y * distance, - ray.origin.z + ray.direction.z * distance, - ] - - return { start, end, distance } - }, [ray, hit]) - - // Animate glow effects - useFrame((state) => { - const time = state.clock.elapsedTime - - // Pulse the origin glow - if (glowRef.current) { - const pulse = 1 + Math.sin(time * 4) * 0.15 - const baseScale = 0.15 + ray.pinchStrength * 0.1 - glowRef.current.scale.setScalar(baseScale * pulse) - } - - // Pulse the hit indicator - if (hitGlowRef.current && hit) { - const pulse = 1 + Math.sin(time * 6) * 0.2 - hitGlowRef.current.scale.setScalar(0.8 * pulse) - } - - // Rotate ripple effect - if (rippleRef.current && ray.isActive) { - rippleRef.current.rotation.z = time * 2 - } - }) - - // Visual properties based on state - const intensity = ray.isActive ? 1 : 0.4 + ray.pinchStrength * 0.4 - const lineWidth = ray.isActive ? 3 : 1.5 + ray.pinchStrength - const glowOpacity = 0.3 + intensity * 0.4 - - return ( - - {/* Main beam - inner bright core */} - - - {/* Outer glow beam */} - - - {/* Origin glow sphere */} - - - - - - {/* Active ripple at origin */} - {ray.isActive && ( - - - - - )} - - {/* Hit indicator */} - {hit && ( - - {/* Inner hit point */} - - - - - - {/* Outer glow */} - - - - - - {/* Expanding rings when active */} - {ray.isActive && ( - <> - - - - - )} - - )} - - {/* Debug: Arm model visualization */} - {showArmModel && ( - - )} - - ) -} - -/** - * Expanding ring animation at hit point - */ -function ExpandingRing({ color, delay }: { color: string; delay: number }) { - const ringRef = useRef(null) - - useFrame((state) => { - if (!ringRef.current) return - - const time = (state.clock.elapsedTime + delay) % 1 - const scale = 0.5 + time * 2 - const opacity = (1 - time) * 0.4 - - ringRef.current.scale.setScalar(scale) - const mat = ringRef.current.material as THREE.MeshBasicMaterial - mat.opacity = opacity - }) - - return ( - - - - - ) -} - -/** - * Debug visualization of the estimated arm model - */ -function ArmModelDebug({ armPose, color }: { armPose: StableRay['armPose']; color: string }) { - const { shoulder, elbow, wrist, pinchPoint } = armPose - - return ( - - {/* Shoulder */} - - - - - - {/* Upper arm (shoulder → elbow) */} - - - {/* Elbow */} - - - - - - {/* Forearm (elbow → wrist) */} - - - {/* Wrist */} - - - - - - {/* Hand (wrist → pinch point) */} - - - {/* Pinch point */} - - - - - - ) -} - -export default LaserPointer diff --git a/packages/graph-viewer/src/hooks/useHandCursor.ts b/packages/graph-viewer/src/hooks/useHandCursor.ts deleted file mode 100644 index 0baca5e..0000000 --- a/packages/graph-viewer/src/hooks/useHandCursor.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * useHandCursor - Simplified Hand Cursor Hook - * - * Treats hand tracking like a 3D touchpad: - * - Index fingertip position on screen = cursor position - * - Pinch (thumb+index close) = click - * - No complex arm models, no acquisition delays, no lock states - * - * Coordinate System: - * - Output is in NDC (Normalized Device Coordinates): x,y in range [-1, 1] - * - X: -1 = left edge, +1 = right edge - * - Y: -1 = bottom edge, +1 = top edge - * - Origin (0,0) = screen center - */ - -import { useState, useEffect, useRef } from 'react' -import type { GestureState, HandLandmarks } from './useHandGestures' - -// Pinch detection thresholds (with hysteresis to prevent flickering) -const PINCH_DOWN_THRESHOLD = 0.70 // Requires intentional pinch to activate -const PINCH_UP_THRESHOLD = 0.45 // Lower threshold to release (hysteresis) - -// Screen edge margin - positions within this margin are considered off-screen -const EDGE_MARGIN = 0.05 // 5% from edge - -// Landmark indices -const INDEX_FINGERTIP = 8 -const THUMB_TIP = 4 - -/** - * Pinch state machine phases: - * - idle: No pinch detected - * - down: Just started pinching this frame (rising edge) - * - held: Continuing to pinch - * - up: Just released pinch this frame (falling edge) - */ -export type PinchPhase = 'idle' | 'down' | 'held' | 'up' - -export interface HandCursorState { - /** Whether the cursor is active (hand detected and visible) */ - isActive: boolean - - /** Screen position in NDC [-1, 1], null if no hand */ - screenPosition: { x: number; y: number } | null - - /** Current pinch state machine phase */ - pinchState: PinchPhase - - /** Raw pinch strength 0-1 */ - pinchStrength: number - - /** Which hand is controlling the cursor */ - activeHand: 'left' | 'right' | null - - /** Whether the cursor is near the screen edge */ - isOffScreen: boolean - - /** Depth value for visual feedback (0 = close/faint, 1 = far/bright) */ - normalizedDepth: number -} - -interface UseHandCursorOptions { - /** Enable/disable cursor tracking */ - enabled?: boolean - /** Prefer left or right hand when both are present */ - preferredHand?: 'left' | 'right' -} - -/** - * Calculate pinch strength from thumb-index distance - */ -function calculatePinchStrength(hand: HandLandmarks): number { - const thumb = hand.landmarks[THUMB_TIP] - const index = hand.landmarks[INDEX_FINGERTIP] - - if (!thumb || !index) return 0 - - // Distance in normalized coordinates (0-1 range each) - const dx = thumb.x - index.x - const dy = thumb.y - index.y - const dz = (thumb.z || 0) - (index.z || 0) - const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) - - // Map distance to pinch strength - // Pinched: ~0.02-0.04, Extended: ~0.15-0.25 - const minDist = 0.03 // Fully pinched - const maxDist = 0.15 // Fully open - const strength = 1 - Math.max(0, Math.min(1, (distance - minDist) / (maxDist - minDist))) - - return strength -} - -/** - * Convert hand landmark position to NDC screen coordinates - */ -function landmarkToNDC(hand: HandLandmarks): { x: number; y: number } { - const fingertip = hand.landmarks[INDEX_FINGERTIP] - if (!fingertip) return { x: 0, y: 0 } - - // Mirror X for selfie camera (webcam shows mirrored view) - const mirroredX = 1 - fingertip.x - - // Convert to NDC: 0→-1, 0.5→0, 1→+1 - // Flip Y so that top of screen = +1, bottom = -1 - return { - x: mirroredX * 2 - 1, - y: (1 - fingertip.y) * 2 - 1, - } -} - -/** - * Check if position is near screen edge (considered off-screen for UI purposes) - */ -function isNearEdge(ndc: { x: number; y: number }): boolean { - const margin = EDGE_MARGIN * 2 // Convert to NDC range (-1 to 1) - return ( - Math.abs(ndc.x) > 1 - margin || - Math.abs(ndc.y) > 1 - margin - ) -} - -/** - * Calculate normalized depth for visual feedback - * Returns 0-1 where 0 = close to camera, 1 = far from camera - */ -function calculateNormalizedDepth(hand: HandLandmarks): number { - const wristZ = hand.landmarks[0]?.z || 0 - - // Detect if Z is in meters (LiDAR: 0.3-3.0m) or normalized (MediaPipe: -0.5 to +0.3) - const isMeters = Math.abs(wristZ) > 0.5 - - if (isMeters) { - // LiDAR in meters: ~0.3m (close) to ~1.2m (far) - return Math.max(0, Math.min(1, (wristZ - 0.3) / 0.9)) - } else { - // MediaPipe: positive Z = closer, negative Z = farther - // Range typically +0.15 (close) to -0.25 (far) - return Math.max(0, Math.min(1, (0.15 - wristZ) / 0.4)) - } -} - -/** - * useHandCursor - Simplified hand cursor tracking - * - * @param gestureState - Current gesture state from useHandGestures - * @param options - Configuration options - * @returns HandCursorState with cursor position and pinch state - */ -export function useHandCursor( - gestureState: GestureState, - options: UseHandCursorOptions = {} -): HandCursorState { - const { enabled = true, preferredHand = 'right' } = options - - // Track previous pinch state for state machine transitions - const prevPinchStrengthRef = useRef(0) - const pinchPhaseRef = useRef('idle') - - // Current cursor state - const [cursorState, setCursorState] = useState({ - isActive: false, - screenPosition: null, - pinchState: 'idle', - pinchStrength: 0, - activeHand: null, - isOffScreen: false, - normalizedDepth: 0.5, - }) - - // Update cursor state based on gesture state - useEffect(() => { - if (!enabled || !gestureState.isTracking) { - if (cursorState.isActive) { - setCursorState({ - isActive: false, - screenPosition: null, - pinchState: 'idle', - pinchStrength: 0, - activeHand: null, - isOffScreen: false, - normalizedDepth: 0.5, - }) - pinchPhaseRef.current = 'idle' - } - return - } - - // Select active hand (prefer right, fall back to left, or use what's available) - let activeHand: HandLandmarks | null = null - let activeHandSide: 'left' | 'right' | null = null - - if (preferredHand === 'right') { - if (gestureState.rightHand) { - activeHand = gestureState.rightHand - activeHandSide = 'right' - } else if (gestureState.leftHand) { - activeHand = gestureState.leftHand - activeHandSide = 'left' - } - } else { - if (gestureState.leftHand) { - activeHand = gestureState.leftHand - activeHandSide = 'left' - } else if (gestureState.rightHand) { - activeHand = gestureState.rightHand - activeHandSide = 'right' - } - } - - if (!activeHand) { - setCursorState({ - isActive: false, - screenPosition: null, - pinchState: 'idle', - pinchStrength: 0, - activeHand: null, - isOffScreen: false, - normalizedDepth: 0.5, - }) - pinchPhaseRef.current = 'idle' - return - } - - // Calculate cursor position and pinch strength - const screenPosition = landmarkToNDC(activeHand) - const pinchStrength = calculatePinchStrength(activeHand) - const isOffScreen = isNearEdge(screenPosition) - const normalizedDepth = calculateNormalizedDepth(activeHand) - - // Pinch state machine with hysteresis - const prevPhase = pinchPhaseRef.current - let newPhase: PinchPhase - - switch (prevPhase) { - case 'idle': - // Transition to 'down' when pinch crosses threshold - newPhase = pinchStrength >= PINCH_DOWN_THRESHOLD ? 'down' : 'idle' - break - case 'down': - // Always transition to 'held' next frame (down is a single-frame event) - newPhase = 'held' - break - case 'held': - // Transition to 'up' when pinch drops below release threshold - newPhase = pinchStrength < PINCH_UP_THRESHOLD ? 'up' : 'held' - break - case 'up': - // Always transition to 'idle' next frame (up is a single-frame event) - newPhase = 'idle' - break - default: - newPhase = 'idle' - } - - pinchPhaseRef.current = newPhase - prevPinchStrengthRef.current = pinchStrength - - setCursorState({ - isActive: true, - screenPosition, - pinchState: newPhase, - pinchStrength, - activeHand: activeHandSide, - isOffScreen, - normalizedDepth, - }) - }, [enabled, gestureState, preferredHand, cursorState.isActive]) - - return cursorState -} - -export default useHandCursor diff --git a/packages/graph-viewer/src/hooks/useHandInteraction.ts b/packages/graph-viewer/src/hooks/useHandInteraction.ts deleted file mode 100644 index 862285e..0000000 --- a/packages/graph-viewer/src/hooks/useHandInteraction.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Hand Interaction Hook - * - * Combines gesture tracking with stable pointer ray and hit detection. - * Provides a complete interaction system for the memory graph: - * - * - Accurate laser pointing with arm model - * - Node hit detection - * - Pinch-to-select with hysteresis - * - Pull/push gestures for Z manipulation - * - Two-hand rotation - * - * This is the main entry point for hand-based graph interaction. - */ - -import { useRef, useCallback, useState } from 'react' -import { useStablePointerRay, findNodeHit, type StableRay, type NodeHit, type NodeSphere } from './useStablePointerRay' -import type { GestureState } from './useHandGestures' -import type { SimulationNode } from '../lib/types' - -export interface InteractionState { - /** Left hand ray (if tracking) */ - leftRay: StableRay | null - /** Right hand ray (if tracking) */ - rightRay: StableRay | null - /** Currently hovered node (ray intersects) */ - hoveredNode: NodeHit | null - /** Selected node (pinch activated on hover) */ - selectedNodeId: string | null - /** Is a node being dragged */ - isDragging: boolean - /** Drag delta for Z manipulation */ - dragDeltaZ: number - /** Two-hand rotation delta */ - rotationDelta: { x: number; y: number } - /** Two-hand zoom delta */ - zoomDelta: number -} - -interface UseHandInteractionOptions { - /** Nodes to test for hit detection */ - nodes: SimulationNode[] - /** Callback when node selection changes */ - onNodeSelect?: (nodeId: string | null) => void - /** Callback when node hover changes */ - onNodeHover?: (nodeId: string | null) => void - /** Disable pinch-to-select behavior (useful when using fist-grab controls) */ - enableSelection?: boolean -} - -// Sensitivity settings -const DRAG_Z_SENSITIVITY = 80 -const ROTATION_SENSITIVITY = 2 -const ZOOM_SENSITIVITY = 150 - -export function useHandInteraction({ - nodes, - onNodeSelect, - onNodeHover, - enableSelection = true, -}: UseHandInteractionOptions) { - // Stable pointer ray processors for each hand - const leftRayProcessor = useStablePointerRay({ handedness: 'left' }) - const rightRayProcessor = useStablePointerRay({ handedness: 'right' }) - - // State - const [interactionState, setInteractionState] = useState({ - leftRay: null, - rightRay: null, - hoveredNode: null, - selectedNodeId: null, - isDragging: false, - dragDeltaZ: 0, - rotationDelta: { x: 0, y: 0 }, - zoomDelta: 0, - }) - - // Previous state for delta calculations - const prevStateRef = useRef<{ - leftZ: number | null - rightZ: number | null - selectedNodeId: string | null - twoHandDistance: number | null - twoHandRotation: number | null - }>({ - leftZ: null, - rightZ: null, - selectedNodeId: null, - twoHandDistance: null, - twoHandRotation: null, - }) - - // Convert SimulationNodes to NodeSpheres for hit testing - const nodeSpheres: NodeSphere[] = nodes.map(n => ({ - id: n.id, - x: n.x ?? 0, - y: n.y ?? 0, - z: n.z ?? 0, - radius: n.radius * 1.5, // Slightly larger hit area - })) - - // Process gesture state and update interaction state - const processGestures = useCallback((gestureState: GestureState) => { - const timestamp = performance.now() / 1000 - const prev = prevStateRef.current - - // Process hand landmarks through stable ray pipeline - const leftRay = gestureState.leftHand - ? leftRayProcessor.processLandmarks(gestureState.leftHand.landmarks, timestamp) - : null - - const rightRay = gestureState.rightHand - ? rightRayProcessor.processLandmarks(gestureState.rightHand.landmarks, timestamp) - : null - - // Determine primary ray (prefer right hand) - const primaryRay = (rightRay?.confidence ?? 0) > (leftRay?.confidence ?? 0) ? rightRay : leftRay - - // Find node hit - let hoveredNode: NodeHit | null = null - if (primaryRay) { - // Convert normalized ray to world coordinates for hit testing - // The ray is in normalized 0-1 space, nodes are in world space (-100 to 100 etc) - const worldRay = { - origin: { - x: (primaryRay.origin.x - 0.5) * 200, - y: -(primaryRay.origin.y - 0.5) * 200, - z: -primaryRay.origin.z * 200, - }, - direction: { - x: primaryRay.direction.x, - y: -primaryRay.direction.y, // Flip Y for world coords - z: -primaryRay.direction.z, - }, - } - hoveredNode = findNodeHit(worldRay, nodeSpheres, 500) - } - - // Handle selection (pinch on hover) - let selectedNodeId = prev.selectedNodeId - let isDragging = false - let dragDeltaZ = 0 - - if (enableSelection && primaryRay?.isActive) { - if (hoveredNode && !prev.selectedNodeId) { - // Start selection - selectedNodeId = hoveredNode.nodeId - onNodeSelect?.(selectedNodeId) - } - - if (selectedNodeId) { - isDragging = true - - // Calculate Z drag from hand movement - const currentZ = primaryRay.origin.z - const isLeftPrimary = leftRay && primaryRay.pinchPoint.x === leftRay.pinchPoint.x - const prevZ = isLeftPrimary ? prev.leftZ : prev.rightZ - - if (prevZ !== null) { - // Negative Z movement (hand toward camera) = push node away - dragDeltaZ = (currentZ - prevZ) * DRAG_Z_SENSITIVITY - } - } - } else { - // Released - clear selection - if (enableSelection && prev.selectedNodeId) { - selectedNodeId = null - onNodeSelect?.(null) - } - } - - // Update hover callback - if (hoveredNode?.nodeId !== prev.selectedNodeId) { - // Don't update hover while dragging the same node - const hoverId = hoveredNode?.nodeId ?? null - const prevHoverId = interactionState.hoveredNode?.nodeId ?? null - if (hoverId !== prevHoverId && !isDragging) { - onNodeHover?.(hoverId) - } - } - - // Two-hand gestures - let rotationDelta = { x: 0, y: 0 } - let zoomDelta = 0 - - if (leftRay?.isActive && rightRay?.isActive) { - // Both hands pinching - two-hand mode - - // Calculate distance between pinch points - const dx = rightRay.pinchPoint.x - leftRay.pinchPoint.x - const dy = rightRay.pinchPoint.y - leftRay.pinchPoint.y - const distance = Math.sqrt(dx * dx + dy * dy) - - // Calculate rotation angle - const rotation = Math.atan2(dy, dx) - - if (prev.twoHandDistance !== null && prev.twoHandRotation !== null) { - // Zoom from distance change - zoomDelta = (distance - prev.twoHandDistance) * ZOOM_SENSITIVITY - - // Rotation from angle change - let rotDelta = rotation - prev.twoHandRotation - // Normalize to -PI to PI - while (rotDelta > Math.PI) rotDelta -= Math.PI * 2 - while (rotDelta < -Math.PI) rotDelta += Math.PI * 2 - - rotationDelta = { - x: rotDelta * ROTATION_SENSITIVITY, - y: 0, // Y rotation from individual hand movements, handled above - } - } - - prev.twoHandDistance = distance - prev.twoHandRotation = rotation - } else { - prev.twoHandDistance = null - prev.twoHandRotation = null - } - - // Update previous state - prev.leftZ = leftRay?.origin.z ?? null - prev.rightZ = rightRay?.origin.z ?? null - prev.selectedNodeId = selectedNodeId - - // Update interaction state - setInteractionState({ - leftRay, - rightRay, - hoveredNode, - selectedNodeId, - isDragging, - dragDeltaZ, - rotationDelta, - zoomDelta, - }) - }, [nodeSpheres, leftRayProcessor, rightRayProcessor, onNodeSelect, onNodeHover]) - - // Reset - const reset = useCallback(() => { - leftRayProcessor.reset() - rightRayProcessor.reset() - prevStateRef.current = { - leftZ: null, - rightZ: null, - selectedNodeId: null, - twoHandDistance: null, - twoHandRotation: null, - } - setInteractionState({ - leftRay: null, - rightRay: null, - hoveredNode: null, - selectedNodeId: null, - isDragging: false, - dragDeltaZ: 0, - rotationDelta: { x: 0, y: 0 }, - zoomDelta: 0, - }) - }, [leftRayProcessor, rightRayProcessor]) - - return { - interactionState, - processGestures, - reset, - } -} - -export default useHandInteraction diff --git a/packages/graph-viewer/src/hooks/useStablePointerRay.ts b/packages/graph-viewer/src/hooks/useStablePointerRay.ts deleted file mode 100644 index 90054a7..0000000 --- a/packages/graph-viewer/src/hooks/useStablePointerRay.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Stable Pointer Ray Hook - * - * Implements Meta Quest-style pointer ray with: - * - Estimated arm model (shoulder → elbow → wrist → pinch) - * - Virtual pivot point behind wrist for stability - * - One Euro Filter for velocity-adaptive smoothing - * - Ray-sphere intersection for node hit detection - * - * The key insight: humans point with their forearm, not their hand. - * Small hand tremors cause huge angular changes if you pivot at the wrist. - * By estimating the elbow and placing the pivot further back, we reduce jitter. - */ - -import { useRef, useCallback } from 'react' -import { PointerRayFilter, type PointerRay } from '../lib/OneEuroFilter' -import type { NormalizedLandmarkList } from '@mediapipe/hands' - -// MediaPipe landmark indices -const WRIST = 0 -const THUMB_TIP = 4 -const INDEX_TIP = 8 -const INDEX_MCP = 5 // Knuckle -const MIDDLE_MCP = 9 - -export interface Vec3 { - x: number - y: number - z: number -} - -export interface StableRay extends PointerRay { - /** Pinch strength 0-1 */ - pinchStrength: number - /** Is ray valid for interaction (pinch > threshold) */ - isActive: boolean - /** Confidence in the ray direction */ - confidence: number - /** The pinch point (thumb-index midpoint) in normalized coords */ - pinchPoint: Vec3 - /** Screen intersection point (where laser hits the screen plane) */ - screenHit: { x: number; y: number } | null - /** Estimated arm pose for visualization */ - armPose: ArmPose -} - -export interface ArmPose { - shoulder: Vec3 - elbow: Vec3 - wrist: Vec3 - pinchPoint: Vec3 -} - -export interface NodeHit { - nodeId: string - distance: number - point: Vec3 -} - -interface UseStablePointerRayOptions { - /** Handedness - affects arm model */ - handedness: 'left' | 'right' - /** Pinch threshold to activate ray (0-1) */ - pinchThreshold?: number - /** Release threshold (should be lower than pinch for hysteresis) */ - releaseThreshold?: number - /** How far behind wrist to place virtual pivot (normalized units) */ - pivotDistance?: number -} - -// Vector math utilities -function sub(a: Vec3, b: Vec3): Vec3 { - return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z } -} - -function scale(v: Vec3, s: number): Vec3 { - return { x: v.x * s, y: v.y * s, z: v.z * s } -} - -function length(v: Vec3): number { - return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) -} - -function normalize(v: Vec3): Vec3 { - const len = length(v) - return len > 0 ? scale(v, 1 / len) : { x: 0, y: 0, z: -1 } -} - - -function distance(a: Vec3, b: Vec3): number { - return length(sub(a, b)) -} - -/** - * Estimate pinch strength from thumb-index distance - * Returns 0 (open) to 1 (fully pinched) - */ -function calculatePinchStrength(landmarks: NormalizedLandmarkList): number { - const thumbTip = landmarks[THUMB_TIP] - const indexTip = landmarks[INDEX_TIP] - const dist = distance( - { x: thumbTip.x, y: thumbTip.y, z: thumbTip.z || 0 }, - { x: indexTip.x, y: indexTip.y, z: indexTip.z || 0 } - ) - // Typical range: 0.02 (pinched) to 0.15 (open) - return Math.max(0, Math.min(1, 1 - (dist - 0.02) / 0.13)) -} - -/** - * Estimate the arm pose from hand landmarks - * Since we can only see the hand, we infer shoulder/elbow positions - */ -function estimateArmPose(landmarks: NormalizedLandmarkList, handedness: 'left' | 'right'): ArmPose { - const wristLm = landmarks[WRIST] - const thumbTip = landmarks[THUMB_TIP] - const indexTip = landmarks[INDEX_TIP] - - const wrist: Vec3 = { - x: wristLm.x, - y: wristLm.y, - z: wristLm.z || 0, - } - - const pinchPoint: Vec3 = { - x: (thumbTip.x + indexTip.x) / 2, - y: (thumbTip.y + indexTip.y) / 2, - z: ((thumbTip.z || 0) + (indexTip.z || 0)) / 2, - } - - // Estimate shoulder position - fixed relative to screen - // Shoulder is off-screen, on the same side as the hand - const isRight = handedness === 'right' - const shoulder: Vec3 = { - x: isRight ? 1.4 : -0.4, // Off screen - y: 1.5, // Below screen - z: 0.6, // Further from camera - } - - // Estimate elbow using anatomical constraints - // Elbow is roughly 35% of the way from shoulder to wrist - // with some lateral offset (natural arm bend) - const shoulderToWrist = sub(wrist, shoulder) - const forearmRatio = 0.35 - const lateralOffset = isRight ? 0.12 : -0.12 // Natural arm bend outward - - // Hand depth affects elbow estimation - // Hand closer to camera = elbow more bent (closer to body) - const depthFactor = Math.max(0, 0.5 - (wrist.z || 0)) - - const elbow: Vec3 = { - x: shoulder.x + shoulderToWrist.x * forearmRatio + lateralOffset * (1 + depthFactor), - y: shoulder.y + shoulderToWrist.y * forearmRatio - 0.08, - z: shoulder.z + shoulderToWrist.z * forearmRatio + depthFactor * 0.1, - } - - return { shoulder, elbow, wrist, pinchPoint } -} - -/** - * Calculate stable pointer ray using the arm model - */ -function calculateStableRay( - armPose: ArmPose, - pivotDistance: number -): PointerRay { - const { elbow, wrist, pinchPoint } = armPose - - // Forearm direction (elbow → wrist) - const forearmDir = normalize(sub(wrist, elbow)) - - // Virtual pivot: offset behind wrist along forearm - // This is the key to stability - small hand movements - // cause smaller angular changes when pivoting from further back - const pivot = sub(wrist, scale(forearmDir, pivotDistance)) - - // Ray direction: from virtual pivot through pinch point - const direction = normalize(sub(pinchPoint, pivot)) - - return { - origin: pivot, - direction, - } -} - -/** - * Calculate where the ray intersects the screen plane (z=0) - */ -function calculateScreenHit(ray: PointerRay): { x: number; y: number } | null { - const { origin, direction } = ray - - // Avoid division by zero - if (Math.abs(direction.z) < 0.0001) return null - - // t = -origin.z / direction.z (intersection with z=0 plane) - const t = -origin.z / direction.z - - // Only forward intersections - if (t < 0) return null - - return { - x: origin.x + direction.x * t, - y: origin.y + direction.y * t, - } -} - -/** - * Estimate confidence in the ray direction - * Based on hand visibility and pose stability - */ -function calculateConfidence(landmarks: NormalizedLandmarkList): number { - // Check visibility of key landmarks - const wrist = landmarks[WRIST] - const thumbTip = landmarks[THUMB_TIP] - const indexTip = landmarks[INDEX_TIP] - - // Visibility is 0-1 if present, otherwise assume low - const wristVis = (wrist as any).visibility ?? 0.5 - const thumbVis = (thumbTip as any).visibility ?? 0.5 - const indexVis = (indexTip as any).visibility ?? 0.5 - - // Check if hand is in reasonable pose (not twisted weirdly) - const indexMcp = landmarks[INDEX_MCP] - const middleMcp = landmarks[MIDDLE_MCP] - - // Palm should face camera - MCPs should be above wrist - const palmFacing = wrist.y > indexMcp.y && wrist.y > middleMcp.y - - const baseConfidence = (wristVis + thumbVis + indexVis) / 3 - const poseBonus = palmFacing ? 0.2 : 0 - - return Math.min(1, baseConfidence + poseBonus) -} - -export function useStablePointerRay(options: UseStablePointerRayOptions) { - const { - handedness, - pinchThreshold = 0.6, - releaseThreshold = 0.35, - pivotDistance = 0.12, - } = options - - // Filter for smoothing - const rayFilterRef = useRef(new PointerRayFilter()) - - // Track previous active state for hysteresis - const wasActiveRef = useRef(false) - - // Process hand landmarks and return stable ray - const processLandmarks = useCallback( - (landmarks: NormalizedLandmarkList | null, timestamp: number): StableRay | null => { - if (!landmarks) { - rayFilterRef.current.reset() - wasActiveRef.current = false - return null - } - - // Calculate pinch strength - const pinchStrength = calculatePinchStrength(landmarks) - - // Hysteresis: different thresholds for activating vs deactivating - const threshold = wasActiveRef.current ? releaseThreshold : pinchThreshold - const isActive = pinchStrength >= threshold - wasActiveRef.current = isActive - - // Estimate arm pose - const armPose = estimateArmPose(landmarks, handedness) - - // Calculate raw ray - const rawRay = calculateStableRay(armPose, pivotDistance) - - // Apply One Euro Filter for stability - const filteredRay = rayFilterRef.current.filter(rawRay, timestamp) - - // Calculate screen intersection - const screenHit = calculateScreenHit(filteredRay) - - // Estimate confidence - const confidence = calculateConfidence(landmarks) - - return { - ...filteredRay, - pinchStrength, - isActive, - confidence, - pinchPoint: armPose.pinchPoint, - screenHit, - armPose, - } - }, - [handedness, pinchThreshold, releaseThreshold, pivotDistance] - ) - - // Reset filter state - const reset = useCallback(() => { - rayFilterRef.current.reset() - wasActiveRef.current = false - }, []) - - return { processLandmarks, reset } -} - -/** - * Ray-Sphere Intersection - * - * Tests if a ray intersects a sphere and returns hit info. - * Used for detecting when the laser points at a node. - */ -export function rayIntersectSphere( - ray: PointerRay, - sphereCenter: Vec3, - sphereRadius: number -): { hit: boolean; distance: number; point: Vec3 } | null { - const oc = sub(ray.origin, sphereCenter) - - const a = ray.direction.x * ray.direction.x + - ray.direction.y * ray.direction.y + - ray.direction.z * ray.direction.z - const b = 2 * (oc.x * ray.direction.x + oc.y * ray.direction.y + oc.z * ray.direction.z) - const c = oc.x * oc.x + oc.y * oc.y + oc.z * oc.z - sphereRadius * sphereRadius - - const discriminant = b * b - 4 * a * c - - if (discriminant < 0) return null - - // Find nearest intersection - const t = (-b - Math.sqrt(discriminant)) / (2 * a) - - if (t < 0) return null - - const point: Vec3 = { - x: ray.origin.x + ray.direction.x * t, - y: ray.origin.y + ray.direction.y * t, - z: ray.origin.z + ray.direction.z * t, - } - - return { hit: true, distance: t, point } -} - -/** - * Test ray against multiple nodes and return closest hit - */ -export interface NodeSphere { - id: string - x: number - y: number - z: number - radius: number -} - -export function findNodeHit( - ray: PointerRay, - nodes: NodeSphere[], - maxDistance: number = 1000 -): NodeHit | null { - let closestHit: NodeHit | null = null - - for (const node of nodes) { - const result = rayIntersectSphere( - ray, - { x: node.x, y: node.y, z: node.z }, - node.radius - ) - - if (result && result.distance < maxDistance) { - if (!closestHit || result.distance < closestHit.distance) { - closestHit = { - nodeId: node.id, - distance: result.distance, - point: result.point, - } - } - } - } - - return closestHit -} - -export default useStablePointerRay From b3b62e5cd9b821c1601dcbf55723523ed86ca19d Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 25 Dec 2025 02:07:41 +0100 Subject: [PATCH 38/47] feat: Improve hand tracking and gesture control UX Enhances hand tracking by refining hand lock acquisition, adding depth-aware pointing and pinch detection, and improving overlay visualization for both MediaPipe and iPhone LiDAR sources. The commit introduces more intentional hand lock gating, depth-based pointing heuristics, and visual feedback for hand state and depth. It also fixes candidate hand tracking, improves inertial panning, and updates the debug overlay to show world Z in meters. --- packages/graph-viewer/src/App.tsx | 1 + .../src/components/GestureDebugOverlay.tsx | 11 +- .../src/components/GraphCanvas.tsx | 259 +++++++++++++++--- .../src/components/Hand2DOverlay.tsx | 98 +++++-- .../src/hooks/useHandLockAndGrab.ts | 237 ++++++++++------ .../src/hooks/useIPhoneHandTracking.ts | 91 +++++- 6 files changed, 547 insertions(+), 150 deletions(-) diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 0b6701e..8bddc4f 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -763,6 +763,7 @@ export default function App() { {/* Gesture Debug Overlay */} diff --git a/packages/graph-viewer/src/components/GestureDebugOverlay.tsx b/packages/graph-viewer/src/components/GestureDebugOverlay.tsx index 2f7b87a..cadc806 100644 --- a/packages/graph-viewer/src/components/GestureDebugOverlay.tsx +++ b/packages/graph-viewer/src/components/GestureDebugOverlay.tsx @@ -56,6 +56,7 @@ export function GestureDebugOverlay({ gestureState, visible }: GestureDebugOverl // Format number for display const fmt = (n: number, decimals = 3) => n.toFixed(decimals) + const fmtMeters = (n: number) => `${fmt(n, 2)}m` // Get landmark name const landmarkName = (i: number) => { @@ -230,11 +231,14 @@ export function GestureDebugOverlay({ gestureState, visible }: GestureDebugOverl
{[0, 4, 8, 12, 16, 20].map((i) => { const lm = leftHand.landmarks[i] + const wm = leftHand.worldLandmarks?.[i] + const worldZ = (wm?.z ?? 0) as number return (
{landmarkName(i)}: - ({fmt(lm.x, 2)}, {fmt(lm.y, 2)}, {fmt(lm.z || 0, 2)}) + ({fmt(lm.x, 2)}, {fmt(lm.y, 2)}, {fmt((lm.z || 0) as number, 2)} + {worldZ > 0 ? ` | ${fmtMeters(worldZ)}` : ''})
) @@ -250,11 +254,14 @@ export function GestureDebugOverlay({ gestureState, visible }: GestureDebugOverl
{[0, 4, 8, 12, 16, 20].map((i) => { const lm = rightHand.landmarks[i] + const wm = rightHand.worldLandmarks?.[i] + const worldZ = (wm?.z ?? 0) as number return (
{landmarkName(i)}: - ({fmt(lm.x, 2)}, {fmt(lm.y, 2)}, {fmt(lm.z || 0, 2)}) + ({fmt(lm.x, 2)}, {fmt(lm.y, 2)}, {fmt((lm.z || 0) as number, 2)} + {worldZ > 0 ? ` | ${fmtMeters(worldZ)}` : ''})
) diff --git a/packages/graph-viewer/src/components/GraphCanvas.tsx b/packages/graph-viewer/src/components/GraphCanvas.tsx index 2e940e5..b9a4d03 100644 --- a/packages/graph-viewer/src/components/GraphCanvas.tsx +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -218,24 +218,24 @@ export function GraphCanvas({ return (
- - + - + /> + {/* MiniMap Navigator */} { + const grabPrevTargetRef = useRef(new THREE.Vector3()) + const grabVelocityRef = useRef(new THREE.Vector3()) + const wasGrabbingRef = useRef(false) + const inertiaActiveRef = useRef(false) + + // Hand aim / selection (point + pinch click) + const handRayPositions = useMemo(() => new Float32Array(6), []) + const handRayGeom = useMemo(() => { + const geom = new THREE.BufferGeometry() + geom.setAttribute('position', new THREE.BufferAttribute(handRayPositions, 3)) + geom.computeBoundingSphere() + return geom + }, [handRayPositions]) + const handRayLine = useMemo(() => { + const mat = new THREE.LineBasicMaterial({ color: 0xfbbf24, transparent: true, opacity: 0.65 }) + const line = new THREE.Line(handRayGeom, mat) + line.visible = false + line.frustumCulled = false + return line + }, [handRayGeom]) + const aimSmoothedRef = useRef({ x: 0.5, y: 0.5 }) + const handHoverIdRef = useRef(null) + const pinchDownRef = useRef(false) + const lastClickMsRef = useRef(0) + + const handRaycaster = useMemo(() => new THREE.Raycaster(), []) + const tmpNdc = useMemo(() => new THREE.Vector2(), []) + const tmpInvMat = useMemo(() => new THREE.Matrix4(), []) + const tmpLocalRay = useMemo(() => new THREE.Ray(), []) + const tmpHitLocal = useMemo(() => new THREE.Vector3(), []) + const tmpHitWorld = useMemo(() => new THREE.Vector3(), []) + const tmpTarget = useMemo(() => new THREE.Vector3(), []) + const tmpInstVel = useMemo(() => new THREE.Vector3(), []) + + // Hand controls (grab inertia + point/pinch selection) + useFrame((_, dt) => { if (!gestureControlEnabled || !groupRef.current) return if (!gestureState.isTracking) return const group = groupRef.current - const isGrabbing = handLock.mode === 'locked' && handLock.grabbed + const isLocked = handLock.mode === 'locked' + const isGrabbing = isLocked && handLock.grabbed + // --- Grab: follow target with damping + inertial coast on release --- if (isGrabbing) { // On first frame of grab, capture current world position - if (grabDeltas.grabStart) { + if (grabDeltas.grabStart || !wasGrabbingRef.current) { grabStartPosRef.current = { x: group.position.x, y: group.position.y, z: group.position.z, } - return + grabPrevTargetRef.current.set(group.position.x, group.position.y, group.position.z) + grabVelocityRef.current.set(0, 0, 0) + inertiaActiveRef.current = false } - // DISPLACEMENT-BASED: Set position relative to grab start + // Target position relative to grab start const startPos = grabStartPosRef.current - group.position.x = startPos.x + grabDeltas.panX - group.position.y = startPos.y + grabDeltas.panY - group.position.z = startPos.z + grabDeltas.panZ + tmpTarget.set(startPos.x + grabDeltas.panX, startPos.y + grabDeltas.panY, startPos.z + grabDeltas.panZ) + + // Estimate target velocity (used for inertial release) + const safeDt = Math.max(1e-4, dt) + tmpInstVel.copy(tmpTarget).sub(grabPrevTargetRef.current).multiplyScalar(1 / safeDt) + grabVelocityRef.current.lerp(tmpInstVel, 0.35) + grabPrevTargetRef.current.copy(tmpTarget) + + // Follow target with a critically-damped feel (reduces jitter while still feeling 1:1) + const follow = 1 - Math.exp(-28 * safeDt) + group.position.lerp(tmpTarget, follow) + } else { + // Released: coast briefly with exponential decay (iOS-style momentum) + if (wasGrabbingRef.current) inertiaActiveRef.current = true + + if (inertiaActiveRef.current) { + const safeDt = Math.max(1e-4, dt) + group.position.x += grabVelocityRef.current.x * safeDt + group.position.y += grabVelocityRef.current.y * safeDt + group.position.z += grabVelocityRef.current.z * safeDt + + const decay = Math.exp(-6.5 * safeDt) + grabVelocityRef.current.multiplyScalar(decay) + + if (grabVelocityRef.current.lengthSq() < 1) { + grabVelocityRef.current.set(0, 0, 0) + inertiaActiveRef.current = false + } + } + } + wasGrabbingRef.current = isGrabbing + + // --- Point + pinch-click selection (only when locked, not grabbing) --- + const pointScore = isLocked ? handLock.metrics.point : 0 + const pinchScore = isLocked ? handLock.metrics.pinch : 0 + const aimActive = isLocked && !isGrabbing && pointScore > 0.55 + + const rayLine = handRayLine + if (!aimActive) { + // Hide aim visuals and clear hover if we were controlling it + if (rayLine) rayLine.visible = false + if (handHoverIdRef.current !== null) { + onNodeHover(null) + handHoverIdRef.current = null + } + pinchDownRef.current = false + return + } + + const handData = handLock.hand === 'right' ? gestureState.rightHand : gestureState.leftHand + const indexTip = handData?.landmarks?.[8] + if (!indexTip) { + if (rayLine) rayLine.visible = false + return + } + + // Smooth the aim point a bit (camera + hand jitter) + const safeDt = Math.max(1e-4, dt) + const aimLerp = 1 - Math.exp(-20 * safeDt) + aimSmoothedRef.current.x += (indexTip.x - aimSmoothedRef.current.x) * aimLerp + aimSmoothedRef.current.y += (indexTip.y - aimSmoothedRef.current.y) * aimLerp + + const aimX = aimSmoothedRef.current.x + const aimY = aimSmoothedRef.current.y + + // Build a ray from the camera through the aim screen point + tmpNdc.set(aimX * 2 - 1, -(aimY * 2 - 1)) + handRaycaster.setFromCamera(tmpNdc, camera) + + // Transform ray into group-local space (nodes are in group coordinates) + tmpInvMat.copy(group.matrixWorld).invert() + tmpLocalRay.copy(handRaycaster.ray).applyMatrix4(tmpInvMat) + + // Find best node hit (ray-sphere intersection) + let bestNode: SimulationNode | null = null + let bestT = Infinity + const radiusScale = displayConfig.nodeSizeScale + const hitRadiusBoost = 1.25 + + for (const n of layoutNodes) { + const cx = n.x ?? 0 + const cy = n.y ?? 0 + const cz = n.z ?? 0 + const r = (n.radius ?? 6) * radiusScale * hitRadiusBoost + + // Ray-sphere intersection (group-local) + const ox = tmpLocalRay.origin.x + const oy = tmpLocalRay.origin.y + const oz = tmpLocalRay.origin.z + const dx = tmpLocalRay.direction.x + const dy = tmpLocalRay.direction.y + const dz = tmpLocalRay.direction.z + + const lx = cx - ox + const ly = cy - oy + const lz = cz - oz + const tca = lx * dx + ly * dy + lz * dz + if (tca < 0) continue + const d2 = lx * lx + ly * ly + lz * lz - tca * tca + const r2 = r * r + if (d2 > r2) continue + const thc = Math.sqrt(r2 - d2) + const t0 = tca - thc + if (t0 > 0 && t0 < bestT) { + bestT = t0 + bestNode = n + } + } + + if (bestNode) { + if (handHoverIdRef.current !== bestNode.id) { + onNodeHover(bestNode) + handHoverIdRef.current = bestNode.id + } + } else if (handHoverIdRef.current !== null) { + onNodeHover(null) + handHoverIdRef.current = null } - }, [gestureControlEnabled, gestureState, handLock, grabDeltas]) + + // Update aim ray visualization (world space) + if (handRayGeom && rayLine) { + rayLine.visible = true + + const o = handRaycaster.ray.origin + const d = handRaycaster.ray.direction + + handRayPositions[0] = o.x + handRayPositions[1] = o.y + handRayPositions[2] = o.z + + if (bestNode && bestT < Infinity) { + tmpHitLocal.set( + (tmpLocalRay.origin.x + tmpLocalRay.direction.x * bestT) as number, + (tmpLocalRay.origin.y + tmpLocalRay.direction.y * bestT) as number, + (tmpLocalRay.origin.z + tmpLocalRay.direction.z * bestT) as number + ) + tmpHitWorld.copy(tmpHitLocal) + group.localToWorld(tmpHitWorld) + } else { + tmpHitWorld.copy(o).addScaledVector(d, 250) + } + + handRayPositions[3] = tmpHitWorld.x + handRayPositions[4] = tmpHitWorld.y + handRayPositions[5] = tmpHitWorld.z + + const attr = handRayGeom.getAttribute('position') as THREE.BufferAttribute | undefined + if (attr) attr.needsUpdate = true + handRayGeom.computeBoundingSphere() + } + + // Pinch click (edge triggered) + const down = pinchScore > 0.85 + if (!pinchDownRef.current && down && bestNode) { + const nowMs = performance.now() + if (nowMs - lastClickMsRef.current > 250) { + lastClickMsRef.current = nowMs + onNodeSelect(bestNode) + } + } + pinchDownRef.current = down + }) return ( <> @@ -619,6 +813,9 @@ function Scene({ /> {/* Graph content */} + {/* Hand aim ray (point + pinch-click) */} + + {/* Batched edges - single draw call for all edges */} {/* Cluster boundaries (rendered behind edges) */} @@ -1157,7 +1354,7 @@ function InstancedNodes({ tempColor.set('#00d4ff') } else if (isLassoSelected) { // Lasso selected nodes: blue tint - tempColor.set(node.color) + tempColor.set(node.color) // Add blue tint by lerping toward blue const blueColor = new THREE.Color('#3b82f6') tempColor.lerp(blueColor, 0.35) diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index dd33e89..5069660 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -11,6 +11,7 @@ import { useState, useEffect, useRef } from 'react' import type { GestureState } from '../hooks/useHandGestures' +import type { HandLockState } from '../hooks/useHandLockAndGrab' // Fingertip indices const FINGERTIPS = [4, 8, 12, 16, 20] @@ -33,11 +34,13 @@ interface SmoothedHand { interface Hand2DOverlayProps { gestureState: GestureState enabled?: boolean + lock?: HandLockState } export function Hand2DOverlay({ gestureState, enabled = true, + lock, }: Hand2DOverlayProps) { // Track smoothed hand positions with ghost effect const [leftSmoothed, setLeftSmoothed] = useState(null) @@ -53,17 +56,20 @@ export function Hand2DOverlay({ // Process left hand if (gestureState.leftHand) { setLeftSmoothed(prev => { - const newLandmarks = gestureState.leftHand!.landmarks.map((lm, i) => { + const hand = gestureState.leftHand! + const newLandmarks = hand.landmarks.map((lm, i) => { + // Prefer world Z (meters for iPhone LiDAR) when available; fall back to normalized z. + const zTarget = (hand.worldLandmarks?.[i]?.z ?? (lm.z || 0)) as number const prevLm = prev?.landmarks[i] if (prevLm && !prev.isGhost) { // Interpolate toward new position return { x: prevLm.x + (lm.x - prevLm.x) * SMOOTHING_FACTOR, y: prevLm.y + (lm.y - prevLm.y) * SMOOTHING_FACTOR, - z: prevLm.z + ((lm.z || 0) - prevLm.z) * SMOOTHING_FACTOR, + z: prevLm.z + (zTarget - prevLm.z) * SMOOTHING_FACTOR, } } - return { x: lm.x, y: lm.y, z: lm.z || 0 } + return { x: lm.x, y: lm.y, z: zTarget } }) return { landmarks: newLandmarks, lastSeen: now, isGhost: false, opacity: 1 } }) @@ -75,16 +81,19 @@ export function Hand2DOverlay({ // Process right hand if (gestureState.rightHand) { setRightSmoothed(prev => { - const newLandmarks = gestureState.rightHand!.landmarks.map((lm, i) => { + const hand = gestureState.rightHand! + const newLandmarks = hand.landmarks.map((lm, i) => { + // Prefer world Z (meters for iPhone LiDAR) when available; fall back to normalized z. + const zTarget = (hand.worldLandmarks?.[i]?.z ?? (lm.z || 0)) as number const prevLm = prev?.landmarks[i] if (prevLm && !prev.isGhost) { return { x: prevLm.x + (lm.x - prevLm.x) * SMOOTHING_FACTOR, y: prevLm.y + (lm.y - prevLm.y) * SMOOTHING_FACTOR, - z: prevLm.z + ((lm.z || 0) - prevLm.z) * SMOOTHING_FACTOR, + z: prevLm.z + (zTarget - prevLm.z) * SMOOTHING_FACTOR, } } - return { x: lm.x, y: lm.y, z: lm.z || 0 } + return { x: lm.x, y: lm.y, z: zTarget } }) return { landmarks: newLandmarks, lastSeen: now, isGhost: false, opacity: 1 } }) @@ -135,8 +144,20 @@ export function Hand2DOverlay({ if (!enabled || !gestureState.isTracking) return null - // Simple opacity - always visible when tracking - const opacityMultiplier = 0.85 + // Visibility gating: + // - Non-acquired hands should be *extremely* faint to avoid the "desk hand blur" problem. + // - Locked hand is bright. + const lockMode = lock?.mode ?? 'idle' + const lockedHand = + lock?.mode === 'locked' + ? lock.hand + : lock?.mode === 'candidate' + ? lock.hand + : null + const baseOpacity = + lockMode === 'locked' ? 0.85 : + lockMode === 'candidate' ? 0.25 : + 0.06 return (
@@ -188,7 +209,7 @@ export function Hand2DOverlay({ color="#4ecdc4" gradientId="hand-gradient-cyan" isGhost={leftSmoothed.isGhost} - opacityMultiplier={opacityMultiplier} + opacityMultiplier={baseOpacity * (lockedHand && lockedHand !== 'left' ? 0.08 : 1)} /> )} @@ -201,7 +222,7 @@ export function Hand2DOverlay({ color="#f72585" gradientId="hand-gradient-magenta" isGhost={rightSmoothed.isGhost} - opacityMultiplier={opacityMultiplier} + opacityMultiplier={baseOpacity * (lockedHand && lockedHand !== 'right' ? 0.08 : 1)} /> )} @@ -238,32 +259,35 @@ function GhostHand({ }: GhostHandProps) { const wristZ = landmarks[0].z || 0 - // Detect if Z is in meters (LiDAR: 0.3-3.0m) or normalized (MediaPipe: -0.5 to +0.3) - const isMeters = Math.abs(wristZ) > 0.5 + // Detect if Z is in meters (LiDAR) vs normalized (MediaPipe-style). + // With iPhone LiDAR we use worldLandmarks.z in meters, which is positive (e.g. 0.3..2.0). + const isMeters = wristZ >= 0.1 && wristZ <= 8.0 - // Z-AXIS INVERSION: "Reach Through Screen" Paradigm + // Z-AXIS INVERSION: "Reach Through Portal" Paradigm // Physical hand closer to camera → Virtual hand appears SMALLER (receding into scene) // Physical hand farther from camera → Virtual hand appears LARGER (coming out of scene) // - // This is OPPOSITE of normal perspective where close=large, far=small. - // It creates the illusion that you're reaching THROUGH the screen INTO the 3D world. + // This creates the illusion that you're reaching THROUGH the screen INTO the 3D world. + // Convention for all sources (MediaPipe, iPhone LiDAR): + // negative Z = closer to camera + // positive Z = farther from camera let scaleFactor = 1.0 let depthOpacity = 1.0 // Additional opacity based on depth if (isMeters) { - // LiDAR in meters: ~0.3m (arm's length) to ~1.5m (extended reach) - // INVERTED: Close (0.3m) → small/faint, Far (1.2m) → large/bright - const normalizedDepth = Math.max(0, Math.min(1, (wristZ - 0.3) / 0.9)) - scaleFactor = 0.4 + normalizedDepth * 1.0 // Range: 0.4 (close) to 1.4 (far) - depthOpacity = 0.5 + normalizedDepth * 0.5 // Range: 0.5 (close/faint) to 1.0 (far/bright) + // LiDAR in meters (positive): ~0.25m (very close) .. ~1.10m (arm's length / comfy) + // Portal mapping: close -> small, far -> large (strong enough to overcome perspective scaling) + const t = Math.max(0, Math.min(1, (wristZ - 0.25) / 0.85)) + scaleFactor = 0.25 + t * 1.55 // 0.25 (close) .. 1.80 (far) + depthOpacity = 0.55 + t * 0.45 // 0.55 (close) .. 1.00 (far) } else { // MediaPipe normalized: positive Z = FARTHER from camera, negative Z = CLOSER // Typical range: -0.25 (close) to +0.15 (far) // "Reach through screen": Close → small/faint, Far → large/bright - const normalizedDepth = Math.max(0, Math.min(1, (wristZ + 0.25) / 0.4)) - scaleFactor = 0.4 + normalizedDepth * 1.0 // Range: 0.4 (close) to 1.4 (far) - depthOpacity = 0.5 + normalizedDepth * 0.5 // Range: 0.5 (close/faint) to 1.0 (far/bright) + const t = Math.max(0, Math.min(1, (wristZ + 0.25) / 0.35)) + scaleFactor = 0.25 + t * 1.55 // 0.25 (close) .. 1.80 (far) + depthOpacity = 0.55 + t * 0.45 // 0.55 (close) .. 1.00 (far) } // Apply the depth opacity to the overall opacity multiplier @@ -271,13 +295,35 @@ function GhostHand({ const clampedScale = Math.max(0.3, Math.min(2.0, scaleFactor)) - // Un-mirror the X coordinate (selfie-style) and convert to SVG space + // "Reach Through Portal" X-axis: Keep the natural webcam mirror + // This makes the virtual hand appear ON THE OTHER SIDE of the screen + // When you point at the camera, the virtual finger points INTO the 3D scene const toSvg = (lm: { x: number; y: number }) => ({ - x: (1 - lm.x) * 100, + x: lm.x * 100, y: lm.y * 100, }) - const points = landmarks.map(toSvg) + // IMPORTANT: apply portal scaling to the landmark positions (not just stroke widths), + // otherwise the overlay will behave like a normal camera feed (closer = bigger). + const rawPoints = landmarks.map(toSvg) + const cx = rawPoints[0]?.x ?? 50 // scale around wrist so position stays intuitive + const cy = rawPoints[0]?.y ?? 50 + + // Extra depth warp per-landmark: makes "pointing at the screen" read as pointing *into* the scene. + // (Index tip physically closer than wrist should appear *deeper* / smaller in the portal model.) + const warpGain = isMeters ? 2.2 : 6.0 + const clampRange = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)) + + const points = rawPoints.map((p, i) => { + const zi = landmarks[i]?.z ?? wristZ + const dz = zi - wristZ // meters or normalized depending on source + const pointScale = clampRange(1 + dz * warpGain, 0.65, 1.45) + const s = clampedScale * pointScale + return { + x: cx + (p.x - cx) * s, + y: cy + (p.y - cy) * s, + } + }) const gloveOpacity = (isGhost ? 0.5 : 0.85) * effectiveOpacityMultiplier // Finger width based on scale - fatter fingers for Master Hand look diff --git a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts index 31947b0..993ef82 100644 --- a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts +++ b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts @@ -36,7 +36,7 @@ export interface HandLockMetrics { export type HandLockState = | { mode: 'idle'; metrics: HandLockMetrics | null } - | { mode: 'candidate'; metrics: HandLockMetrics; frames: number } + | { mode: 'candidate'; hand: HandSide; metrics: HandLockMetrics; frames: number } | { mode: 'locked' hand: HandSide @@ -66,11 +66,12 @@ export interface CloudControlDeltas { const DEFAULT_CONFIDENCE = 0.7 // Tunables (these matter a lot for UX) -const ACQUIRE_FRAMES_REQUIRED = 4 +const ACQUIRE_FRAMES_REQUIRED = 8 const LOCK_PERSIST_MS = 2000 // 2 seconds before unlocking when hand leaves frame -const SPREAD_THRESHOLD = 0.65 -const PALM_FACING_THRESHOLD = 0.55 +// Make acquisition VERY intentional: open palm + spread fingers + palm facing camera. +const SPREAD_THRESHOLD = 0.78 +const PALM_FACING_THRESHOLD = 0.72 const GRAB_ON_THRESHOLD = 0.72 const GRAB_OFF_THRESHOLD = 0.45 @@ -90,6 +91,19 @@ function safeDiv(a: number, b: number, fallback = 0) { return b !== 0 ? a / b : fallback } +function isLikelyMetersZ(z: unknown): z is number { + return typeof z === 'number' && Number.isFinite(z) && z > 0.1 && z < 8 +} + +function depthTowardCameraScore(wristZ: number, tipZ: number, isMeters: boolean) { + // Positive when tip is closer to camera than wrist. + // iPhone meters: smaller = closer. MediaPipe-like normalized: more negative = closer. + const delta = wristZ - tipZ + const deadzone = isMeters ? 0.015 : 0.01 + const fullScale = isMeters ? 0.08 : 0.06 + return clamp(safeDiv(delta - deadzone, fullScale), 0, 1) +} + function fingerExtensionScore( wrist: { x: number; y: number }, mcp: { x: number; y: number }, @@ -110,6 +124,7 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | if (!handData) return null const lm = handData.landmarks + const wm = handData.worldLandmarks || lm // Required joints const wrist = lm[0] const indexMcp = lm[5] @@ -141,8 +156,10 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | const palmFacing = clamp(safeDiv((wrist.y - (indexMcp.y + middleMcp.y) / 2) - 0.02, 0.12), 0, 1) * 2 - 1 // Pinch (thumb-index) + const pinchRay = hand === 'right' ? state.rightPinchRay : state.leftPinchRay const pinchDist = length2(thumbTip.x - indexTip.x, thumbTip.y - indexTip.y) - const pinch = clamp(1 - safeDiv(pinchDist - 0.02, 0.13), 0, 1) + const pinch2d = clamp(1 - safeDiv(pinchDist - 0.02, 0.13), 0, 1) + const pinch = clamp((pinchRay?.strength ?? pinch2d) as number, 0, 1) // Pointing pose score: // index extended while the other 3 fingers are relatively curled. @@ -155,34 +172,45 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | // Point score: index extended (idxExt high) AND other fingers curled (others low) // If index is extended more than others, it's pointing const pointRaw = clamp((idxExt - others) * 2, 0, 1) - const point = idxExt > 0.5 && others < 0.5 ? pointRaw : 0 + const point2d = idxExt > 0.5 && others < 0.5 ? pointRaw : 0 + + // Depth-based pointing: + // When "pointing at the screen" the silhouette can still look fist-like in 2D. + // LiDAR gives a strong signal: index tip moves toward the camera while the other fingers stay back/curled. + const wristWz = (wm[0]?.z ?? wrist.z ?? 0) as number + const indexTipWz = (wm[8]?.z ?? indexTip.z ?? 0) as number + const middleTipWz = (wm[12]?.z ?? middleTip.z ?? 0) as number + const ringTipWz = (wm[16]?.z ?? ringTip.z ?? 0) as number + const pinkyTipWz = (wm[20]?.z ?? pinkyTip.z ?? 0) as number + + const isMeters = isLikelyMetersZ(wristWz) + const idxToward = depthTowardCameraScore(wristWz, indexTipWz, isMeters) + const midToward = depthTowardCameraScore(wristWz, middleTipWz, isMeters) + const ringToward = depthTowardCameraScore(wristWz, ringTipWz, isMeters) + const pinkyToward = depthTowardCameraScore(wristWz, pinkyTipWz, isMeters) + const othersToward = clamp((midToward + ringToward + pinkyToward) / 3, 0, 1) + const pointDepth = clamp(idxToward * (1 - othersToward * 0.9), 0, 1) + + const point = clamp(Math.max(point2d, pointDepth), 0, 1) // Grab: closed fist = ALL fingers curled including index // Exclude index from grab calculation to distinguish from pointing - const hasGrabStrength = - typeof state.grabStrength === 'number' && - state.handsDetected >= 1 && - state.grabStrength > 0 // avoid default 0 from sources that don't compute it - let grab = hasGrabStrength ? clamp(state.grabStrength, 0, 1) : 0 - if (!hasGrabStrength) { - // For grab, require ALL fingers to be curled (including index) - const dw1 = length2(indexTip.x - wrist.x, indexTip.y - wrist.y) - const dw2 = length2(middleTip.x - wrist.x, middleTip.y - wrist.y) - const dw3 = length2(ringTip.x - wrist.x, ringTip.y - wrist.y) - const dw4 = length2(pinkyTip.x - wrist.x, pinkyTip.y - wrist.y) - const avgDw = (dw1 + dw2 + dw3 + dw4) / 4 - // All fingers must be close to wrist - const allCurled = dw1 < 0.15 && dw2 < 0.15 && dw3 < 0.15 && dw4 < 0.15 - grab = allCurled ? clamp(1 - safeDiv(avgDw - 0.08, 0.07), 0, 1) : 0 - } + // Prefer per-hand heuristic. (state.grabStrength is only reliable in the single-hand path.) + const dw1 = length2(indexTip.x - wrist.x, indexTip.y - wrist.y) + const dw2 = length2(middleTip.x - wrist.x, middleTip.y - wrist.y) + const dw3 = length2(ringTip.x - wrist.x, ringTip.y - wrist.y) + const dw4 = length2(pinkyTip.x - wrist.x, pinkyTip.y - wrist.y) + const avgDw = (dw1 + dw2 + dw3 + dw4) / 4 + const grab2d = clamp(1 - safeDiv(avgDw - 0.08, 0.17), 0, 1) + let grab = + state.handsDetected === 1 && typeof state.grabStrength === 'number' && state.grabStrength > 0 + ? clamp(state.grabStrength, 0, 1) + : grab2d // Mutual exclusion: if pointing, suppress grab - if (point > 0.5) { - grab = 0 - } + if (point > 0.55) grab = 0 // Depth: prefer pinch ray origin z when present (iPhone LiDAR mapped into landmarks z) - const pinchRay = hand === 'right' ? state.rightPinchRay : state.leftPinchRay const depth = (pinchRay?.origin.z ?? wrist.z ?? 0) as number // Confidence: use landmark visibility if present; else assume ok @@ -193,7 +221,15 @@ function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | } function isAcquirePose(m: HandLockMetrics) { - return m.spread >= SPREAD_THRESHOLD && m.palmFacing >= PALM_FACING_THRESHOLD && m.confidence >= 0.4 + // Additional gates: prevent accidental acquire from "pointing at screen" or semi-closed poses. + return ( + m.spread >= SPREAD_THRESHOLD && + m.palmFacing >= PALM_FACING_THRESHOLD && + m.grab <= 0.25 && + m.pinch <= 0.25 && + m.point <= 0.25 && + m.confidence >= 0.6 + ) } export function useHandLockAndGrab(state: GestureState, enabled: boolean) { @@ -204,8 +240,13 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { const right = enabled ? computeMetrics(state, 'right') : null const left = enabled ? computeMetrics(state, 'left') : null - // For now, single-hand only: prefer right if present, else left. - const chosenHand: HandSide | null = right ? 'right' : left ? 'left' : null + // Choose a hand to consider when not locked: + // - Prefer a hand currently holding the acquire pose + // - Otherwise prefer right if present, else left + const rightAcquire = !!right && isAcquirePose(right) + const leftAcquire = !!left && isAcquirePose(left) + const chosenHand: HandSide | null = + rightAcquire ? 'right' : leftAcquire ? 'left' : right ? 'right' : left ? 'left' : null const metrics = chosenHand === 'right' ? right : chosenHand === 'left' ? left : null const next = useMemo((): { lock: HandLockState; deltas: CloudControlDeltas } => { @@ -216,75 +257,36 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { const prev = lockRef.current - // No hand seen - if (!chosenHand || !metrics) { - if (prev.mode === 'locked') { - // persist lock briefly + // Locked mode: ONLY the locked hand can drive state; never switch to the other hand implicitly. + if (prev.mode === 'locked') { + const lockedMetrics = prev.hand === 'right' ? right : left + const handData = prev.hand === 'right' ? state.rightHand : state.leftHand + + // Locked hand not currently seen + if (!lockedMetrics || !handData) { + // Persist lock briefly, but don't let another hand keep it alive. if (nowMs - prev.lastSeenMs <= LOCK_PERSIST_MS) { const persisted: HandLockState = { ...prev, metrics: prev.metrics } lockRef.current = persisted return { lock: persisted, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } + lockRef.current = { mode: 'idle', metrics: null } + return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } } - lockRef.current = { mode: 'idle', metrics: null } - return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } - } - - // Hand seen: update FSM - if (prev.mode === 'idle') { - if (isAcquirePose(metrics)) { - const candidate: HandLockState = { mode: 'candidate', metrics, frames: 1 } - lockRef.current = candidate - return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } - } - const idle: HandLockState = { mode: 'idle', metrics } - lockRef.current = idle - return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } - } - if (prev.mode === 'candidate') { - if (isAcquirePose(metrics)) { - const frames = prev.frames + 1 - if (frames >= ACQUIRE_FRAMES_REQUIRED) { - // lock! - const handData = chosenHand === 'right' ? state.rightHand : state.leftHand - const wrist = handData?.landmarks[0] - const locked: HandLockState = { - mode: 'locked', - hand: chosenHand, - metrics, - lockedAtMs: nowMs, - neutral: { x: wrist?.x ?? 0.5, y: wrist?.y ?? 0.5, depth: metrics.depth }, - grabbed: false, - lastSeenMs: nowMs, - } - lockRef.current = locked - return { lock: locked, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } - } - const candidate: HandLockState = { mode: 'candidate', metrics, frames } - lockRef.current = candidate - return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } - } - // lost candidate - const idle: HandLockState = { mode: 'idle', metrics } - lockRef.current = idle - return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } - } - - // locked - if (prev.mode === 'locked') { - const handData = prev.hand === 'right' ? state.rightHand : state.leftHand - const wrist = handData?.landmarks[0] + const wrist = handData.landmarks[0] const x = wrist?.x ?? prev.neutral.x const y = wrist?.y ?? prev.neutral.y // Grab hysteresis const grabbed = - prev.grabbed ? metrics.grab >= GRAB_OFF_THRESHOLD : metrics.grab >= GRAB_ON_THRESHOLD + prev.grabbed + ? lockedMetrics.grab >= GRAB_OFF_THRESHOLD + : lockedMetrics.grab >= GRAB_ON_THRESHOLD const lock: HandLockState = { ...prev, - metrics, + metrics: lockedMetrics, grabbed, lastSeenMs: nowMs, } @@ -296,18 +298,18 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { if (isFirstGrabFrame) { // First frame of grab - set anchor and signal to capture world position - lock.grabAnchor = { x, y, depth: metrics.depth } + lock.grabAnchor = { x, y, depth: lockedMetrics.depth } deltas.grabStart = true lockRef.current = lock return { lock, deltas } } - const anchor = prev.grabAnchor ?? { x, y, depth: metrics.depth } + const anchor = prev.grabAnchor ?? { x, y, depth: lockedMetrics.depth } // Calculate displacement from anchor (how far hand moved since grab started) const dx = x - anchor.x // hand moved right in screen space (0-1 normalized) const dy = y - anchor.y // hand moved down in screen space - const dz = metrics.depth - anchor.depth // hand moved toward/away from camera + const dz = lockedMetrics.depth - anchor.depth // hand moved toward/away from camera // PAN the world: displacement-based, not velocity // Scale: moving hand across half the screen (~0.5) should move graph ~150 world units @@ -334,6 +336,55 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { return { lock, deltas } } + // No hand seen + if (!chosenHand || !metrics) { + lockRef.current = { mode: 'idle', metrics: null } + return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } + } + + // Hand seen: update FSM + if (prev.mode === 'idle') { + if (isAcquirePose(metrics)) { + const candidate: HandLockState = { mode: 'candidate', hand: chosenHand, metrics, frames: 1 } + lockRef.current = candidate + return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } + } + const idle: HandLockState = { mode: 'idle', metrics } + lockRef.current = idle + return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } + } + + if (prev.mode === 'candidate') { + const candidateHand = prev.hand + const candidateMetrics = candidateHand === 'right' ? right : left + if (candidateMetrics && isAcquirePose(candidateMetrics)) { + const frames = prev.frames + 1 + if (frames >= ACQUIRE_FRAMES_REQUIRED) { + // lock! + const handData = candidateHand === 'right' ? state.rightHand : state.leftHand + const wrist = handData?.landmarks[0] + const locked: HandLockState = { + mode: 'locked', + hand: candidateHand, + metrics: candidateMetrics, + lockedAtMs: nowMs, + neutral: { x: wrist?.x ?? 0.5, y: wrist?.y ?? 0.5, depth: candidateMetrics.depth }, + grabbed: false, + lastSeenMs: nowMs, + } + lockRef.current = locked + return { lock: locked, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } + } + const candidate: HandLockState = { mode: 'candidate', hand: candidateHand, metrics: candidateMetrics, frames } + lockRef.current = candidate + return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } + } + // lost candidate + const idle: HandLockState = { mode: 'idle', metrics } + lockRef.current = idle + return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } + } + lockRef.current = { mode: 'idle', metrics } return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } } }, [ @@ -354,6 +405,20 @@ export function useHandLockAndGrab(state: GestureState, enabled: boolean) { state.grabStrength, state.leftPinchRay, state.rightPinchRay, + right?.spread, + right?.palmFacing, + right?.point, + right?.pinch, + right?.grab, + right?.depth, + right?.confidence, + left?.spread, + left?.palmFacing, + left?.point, + left?.pinch, + left?.grab, + left?.depth, + left?.confidence, ]) return next diff --git a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts index 8599022..1533cee 100644 --- a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts +++ b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts @@ -92,6 +92,45 @@ function distance3D(a: IPhoneLandmark, b: IPhoneLandmark): number { return Math.sqrt(dx * dx + dy * dy + dz * dz) } +function distance2D(a: { x: number; y: number }, b: { x: number; y: number }): number { + const dx = a.x - b.x + const dy = a.y - b.y + return Math.sqrt(dx * dx + dy * dy) +} + +function isPointingPose(landmarks: NormalizedLandmarkList): boolean { + // Similar heuristic as MediaPipe path: index extended, others curled. + const wrist = landmarks[0] + const indexMcp = landmarks[5] + const middleMcp = landmarks[9] + const ringMcp = landmarks[13] + const pinkyMcp = landmarks[17] + + const indexTip = landmarks[8] + const middleTip = landmarks[12] + const ringTip = landmarks[16] + const pinkyTip = landmarks[20] + + const idx = distance2D(indexTip, wrist) - distance2D(indexMcp, wrist) + const mid = distance2D(middleTip, wrist) - distance2D(middleMcp, wrist) + const ring = distance2D(ringTip, wrist) - distance2D(ringMcp, wrist) + const pinky = distance2D(pinkyTip, wrist) - distance2D(pinkyMcp, wrist) + + // Index significantly more extended than other fingers + const othersAvg = (mid + ring + pinky) / 3 + return idx > 0.06 && othersAvg < 0.04 +} + +function getPointDirection2D(landmarks: NormalizedLandmarkList): { x: number; y: number } { + const indexTip = landmarks[8] + const indexMcp = landmarks[5] + const dx = indexTip.x - indexMcp.x + // Y: make "up" positive for display + const dy = indexMcp.y - indexTip.y + const len = Math.sqrt(dx * dx + dy * dy) || 1 + return { x: dx / len, y: dy / len } +} + // Calculate pinch strength from iPhone landmarks function calculatePinchStrength(landmarks: Record): number { const thumbTip = landmarks['VNHLKTTIP'] @@ -159,17 +198,46 @@ function calculatePinchRay(landmarks: Record, hasLiDAR: } // Normalize LiDAR depth (meters) to MediaPipe-like relative depth -// LiDAR: 0.3m (close) to 3.0m (far) -> MediaPipe-like: 0.1 to -0.3 +// MediaPipe convention: negative Z = closer to camera, positive Z = farther +// LiDAR: 0.3m (close) to 3.0m (far) -> MediaPipe-like: -0.15 to +0.2 // Reference: ~1.0m is "neutral" -> 0 function normalizeLiDARDepth(depthMeters: number, hasLiDAR: boolean): number { if (!hasLiDAR || depthMeters === 0) return 0 - // Invert and scale: closer = positive, farther = negative - // At 1.0m -> 0, at 0.5m -> 0.1, at 2.0m -> -0.2 - const normalized = (1.0 - depthMeters) * 0.2 + // Match MediaPipe convention: closer = negative, farther = positive + // At 1.0m -> 0, at 0.5m -> -0.1, at 2.0m -> +0.2 + const normalized = (depthMeters - 1.0) * 0.2 return Math.max(-0.5, Math.min(0.5, normalized)) } +// Convert iPhone landmarks to a "world" format where Z is preserved in meters (LiDAR). +// This is useful for debugging and for future 1:1 physical mapping. +function convertToWorldLandmarksMeters( + landmarks: Record, + hasLiDAR: boolean = false +): NormalizedLandmarkList { + const result: NormalizedLandmarkList = [] + + // Initialize all 21 landmarks with defaults + for (let i = 0; i < 21; i++) { + result.push({ x: 0.5, y: 0.5, z: 0, visibility: 0 }) + } + + for (const [name, idx] of Object.entries(LANDMARK_MAP)) { + const lm = landmarks[name] + if (lm) { + result[idx] = { + x: lm.x, + y: lm.y, + z: hasLiDAR ? lm.z : 0, + visibility: 1, + } + } + } + + return result +} + // Convert iPhone landmarks to MediaPipe-compatible format function convertToMediaPipeLandmarks(landmarks: Record, hasLiDAR: boolean = false): NormalizedLandmarkList { const result: NormalizedLandmarkList = [] @@ -272,9 +340,10 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} } const landmarks = convertToMediaPipeLandmarks(hand.landmarks, hand.hasLiDARDepth) + const worldLandmarks = convertToWorldLandmarksMeters(hand.landmarks, hand.hasLiDARDepth) const handData: HandLandmarks = { landmarks, - worldLandmarks: landmarks, + worldLandmarks, handedness: hand.handedness === 'left' ? 'Left' : 'Right', } @@ -322,6 +391,18 @@ export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {} newState.rotateDelta = newState.twoHandRotation - prev.twoHandRotation } + // Single-hand pointing (debug + optional features) + if (newState.handsDetected === 1) { + const pointingHandData = newState.rightHand || newState.leftHand + if (pointingHandData) { + const landmarks = pointingHandData.landmarks + if (isPointingPose(landmarks)) { + newState.pointingHand = pointingHandData.handedness === 'Right' ? 'right' : 'left' + newState.pointDirection = getPointDirection2D(landmarks) + } + } + } + // Pinch strength (smoothed) const primaryHand = newState.rightHand || newState.leftHand if (primaryHand) { From 604f629b22b28df7f8f1dfd8c630d8f78055394a Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 25 Dec 2025 04:17:04 +0100 Subject: [PATCH 39/47] feat: Add hand recording and playback for automated testing Introduces hooks for recording and replaying hand gesture data, enabling automated testing and playback of hand interactions. Updates gesture state to include pinchPoint for direct pinch selection, refactors selection logic to use pinchPoint, and adds visual feedback for pinch pre-select. UI now displays recording and playback indicators, and exposes global automation APIs in test mode. --- packages/graph-viewer/src/App.tsx | 86 +++- .../src/components/GraphCanvas.tsx | 246 +++++----- .../src/components/SelectionHighlight.tsx | 102 +++- .../graph-viewer/src/hooks/useHandGestures.ts | 7 +- .../src/hooks/useHandLockAndGrab.ts | 16 + .../graph-viewer/src/hooks/useHandPlayback.ts | 440 ++++++++++++++++++ .../src/hooks/useHandRecording.ts | 364 +++++++++++++++ .../src/hooks/useIPhoneHandTracking.ts | 95 +++- .../graph-viewer/src/utils/OneEuroFilter.ts | 273 +++++++++++ 9 files changed, 1483 insertions(+), 146 deletions(-) create mode 100644 packages/graph-viewer/src/hooks/useHandPlayback.ts create mode 100644 packages/graph-viewer/src/hooks/useHandRecording.ts create mode 100644 packages/graph-viewer/src/utils/OneEuroFilter.ts diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 8bddc4f..668d3de 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -23,6 +23,8 @@ import { LassoOverlay } from './components/LassoOverlay' import { SelectionActions } from './components/SelectionActions' import { TagCloud } from './components/TagCloud' import { useHandLockAndGrab } from './hooks/useHandLockAndGrab' +import { useHandRecording, downloadRecording, listSavedRecordings, loadRecordingFromStorage } from './hooks/useHandRecording' +import { useHandPlayback } from './hooks/useHandPlayback' import { useTagCloud } from './hooks/useTagCloud' import { useKeyboardNavigation } from './hooks/useKeyboardNavigation' import { useBookmarks, type Bookmark } from './hooks/useBookmarks' @@ -58,6 +60,7 @@ const DEFAULT_GESTURE_STATE: GestureState = { pointDirection: null, pinchStrength: 0, grabStrength: 0, + pinchPoint: null, leftPinchRay: null, rightPinchRay: null, activePinchRay: null, @@ -163,6 +166,56 @@ export default function App() { }, []) const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) + + // Test mode - check URL param for automated testing + const [isTestMode] = useState(() => { + const params = new URLSearchParams(window.location.search) + return params.get('test') === 'true' + }) + + // Hand recording/playback for automated testing + const recording = useHandRecording({ autoDownload: false }) + const playback = useHandPlayback({ + logEvents: true, + exposeGlobal: true, + onGestureChange: (state) => { + // When playing back, use the playback gesture state + if (playback.isPlaying) { + setGestureState(state) + } + }, + }) + + // Expose recording controls globally for automation + useEffect(() => { + if (isTestMode) { + const api = { + // Recording + startRecording: recording.startRecording, + stopRecording: () => { + const rec = recording.stopRecording() + if (rec) downloadRecording(rec) + return rec + }, + isRecording: () => recording.isRecording, + // Playback + loadRecording: playback.loadRecording, + play: playback.play, + pause: playback.pause, + stop: playback.stop, + seek: playback.seek, + setSpeed: playback.setSpeed, + // Utilities + listRecordings: listSavedRecordings, + loadFromStorage: loadRecordingFromStorage, + getGestureState: () => gestureState, + } + ;(window as unknown as Record).__handTest = api + console.log('[TEST MODE] Enabled. Use window.__handTest for automation') + console.log('[TEST MODE] Commands: startRecording(), stopRecording(), listRecordings(), loadFromStorage(key), play(), pause()') + } + }, [isTestMode, recording, playback, gestureState]) + // Tracking source - check URL param on mount, then allow UI toggle const [trackingSource, setTrackingSource] = useState<'mediapipe' | 'iphone'>(() => { const params = new URLSearchParams(window.location.search) @@ -233,7 +286,11 @@ export default function App() { const handleGestureStateChange = useCallback((state: GestureState) => { setGestureState(state) - }, []) + // Record frame if recording is active + if (recording.isRecording) { + recording.recordFrame(state, trackingInfo.hasLiDAR) + } + }, [recording.isRecording, recording.recordFrame, trackingInfo.hasLiDAR]) const { lock: handLock } = useHandLockAndGrab(gestureState, gestureControlEnabled) @@ -672,6 +729,33 @@ export default function App() { )} + {/* Recording Indicator (when recording) */} + {recording.isRecording && ( +
+
+ + REC {Math.floor(recording.duration / 1000)}s ({recording.frameCount}) + +
+ )} + + {/* Playback Indicator (when playing) */} + {playback.isPlaying && ( +
+
+ + PLAY {Math.floor(playback.currentTime / 1000)}s / {Math.floor(playback.duration / 1000)}s + +
+ )} + + {/* Test Mode Indicator */} + {isTestMode && !recording.isRecording && !playback.isPlaying && ( +
+ TEST MODE +
+ )} + {/* Settings Panel Toggle */} + {/* Reset View */} + + {/* Debug Overlay Toggle (only show when gestures enabled) */} {gestureControlEnabled && ( + )} + {/* MiniMap Navigator */} | null, + seedMode: 'tags' as ClusterMode, } // Helper to create data signature @@ -53,15 +56,21 @@ function getPrimaryTag(node: GraphNode): string { return node.tags[0] || 'untagged' } -// Generate deterministic position offset for a tag (for initial clustering) -function getTagPosition(tag: string, _index: number): { tx: number; ty: number; tz: number } { +function getSeedKey(node: GraphNode, mode: ClusterMode): string | null { + if (mode === 'tags') return getPrimaryTag(node) + if (mode === 'type') return node.type + return null +} + +// Generate deterministic position offset for a group key (for initial clustering) +function getSeedPosition(key: string, _index: number): { tx: number; ty: number; tz: number } { // Hash the tag to get a deterministic angle let hash = 0 - for (let i = 0; i < tag.length; i++) { - hash = tag.charCodeAt(i) + ((hash << 5) - hash) + for (let i = 0; i < key.length; i++) { + hash = key.charCodeAt(i) + ((hash << 5) - hash) } const angle = (Math.abs(hash) % 360) * (Math.PI / 180) - const radius = 100 + (Math.abs(hash) % 80) // 100-180 radius for tag clusters + const radius = 100 + (Math.abs(hash) % 80) // 100-180 radius for group clusters return { tx: radius * Math.cos(angle), @@ -70,23 +79,40 @@ function getTagPosition(tag: string, _index: number): { tx: number; ty: number; } } +// Evenly distributed points on a sphere (deterministic) +function getFibonacciPosition(index: number, total: number, radius: number) { + const safeTotal = Math.max(total, 1) + const phi = Math.acos(1 - (2 * (index + 0.5)) / safeTotal) + const theta = Math.PI * (1 + Math.sqrt(5)) * index + return { + x: radius * Math.sin(phi) * Math.cos(theta), + y: radius * Math.sin(phi) * Math.sin(theta), + z: radius * Math.cos(phi), + } +} + // Helper to run the force simulation (pure function, no React) function computeLayout( nodes: GraphNode[], edges: GraphEdge[], forceConfig: ForceConfig, - existingNodes: SimulationNode[] + existingNodes: SimulationNode[], + seedMode: ClusterMode ): SimulationNode[] { - // Group nodes by primary tag for initial positioning - const tagGroups = new Map() - for (const node of nodes) { - const tag = getPrimaryTag(node) - if (!tagGroups.has(tag)) tagGroups.set(tag, []) - tagGroups.get(tag)!.push(node) + const normalizedSeedMode = seedMode === 'semantic' ? 'none' : seedMode + const useGroupedSeeds = normalizedSeedMode === 'tags' || normalizedSeedMode === 'type' + const seedGroups = new Map() + + if (useGroupedSeeds) { + for (const node of nodes) { + const key = getSeedKey(node, normalizedSeedMode) || 'ungrouped' + if (!seedGroups.has(key)) seedGroups.set(key, []) + seedGroups.get(key)!.push(node) + } } - // Create simulation nodes with initial positions clustered by tag - const simNodes: SimulationNode[] = nodes.map((node) => { + // Create simulation nodes with seeded positions + const simNodes: SimulationNode[] = nodes.map((node, index) => { // Check if we have existing position for this node const existing = existingNodes.find((n) => n.id === node.id) if (existing) { @@ -101,22 +127,37 @@ function computeLayout( } } - // Get tag-based cluster position - const tag = getPrimaryTag(node) - const tagNodes = tagGroups.get(tag) || [] - const indexInTag = tagNodes.indexOf(node) - const { tx, ty, tz } = getTagPosition(tag, indexInTag) + if (useGroupedSeeds) { + const key = getSeedKey(node, normalizedSeedMode) || 'ungrouped' + const groupNodes = seedGroups.get(key) || [] + const indexInGroup = groupNodes.indexOf(node) + const { tx, ty, tz } = getSeedPosition(key, indexInGroup) - // Add small random offset within cluster (Fibonacci-like spiral) - const localPhi = Math.acos(1 - (2 * (indexInTag + 0.5)) / Math.max(tagNodes.length, 1)) - const localTheta = Math.PI * (1 + Math.sqrt(5)) * indexInTag - const localRadius = 3 + (1 - node.importance) * 20 // Tighter local spread + // Add small deterministic offset within cluster (Fibonacci-like spiral) + const localPhi = Math.acos(1 - (2 * (indexInGroup + 0.5)) / Math.max(groupNodes.length, 1)) + const localTheta = Math.PI * (1 + Math.sqrt(5)) * indexInGroup + const localRadius = 3 + (1 - node.importance) * 20 // Tighter local spread + + return { + ...node, + x: tx + localRadius * Math.sin(localPhi) * Math.cos(localTheta), + y: ty + localRadius * Math.sin(localPhi) * Math.sin(localTheta), + z: tz + localRadius * Math.cos(localPhi), + vx: 0, + vy: 0, + vz: 0, + } + } + + const baseRadius = 110 + const radius = baseRadius + (1 - node.importance) * 40 + const { x, y, z } = getFibonacciPosition(index, nodes.length, radius) return { ...node, - x: tx + localRadius * Math.sin(localPhi) * Math.cos(localTheta), - y: ty + localRadius * Math.sin(localPhi) * Math.sin(localTheta), - z: tz + localRadius * Math.cos(localPhi), + x, + y, + z, vx: 0, vy: 0, vz: 0, @@ -191,6 +232,7 @@ export function useForceLayout({ edges, forceConfig = DEFAULT_FORCE_CONFIG, useServerPositions = false, + seedMode = 'tags', }: UseForceLayoutOptions): LayoutState & { reheat: () => void } { const [isSimulating, setIsSimulating] = useState(false) @@ -219,6 +261,7 @@ export function useForceLayout({ // Update cache with server-provided positions layoutCache.signature = createDataSignature(nodes) + '-server' layoutCache.nodes = serverNodes + layoutCache.seedMode = seedMode return serverNodes } } @@ -226,19 +269,31 @@ export function useForceLayout({ const signature = createDataSignature(nodes) // Check cache - if signature matches, return cached nodes - if (signature === layoutCache.signature && layoutCache.nodes.length > 0) { + if ( + signature === layoutCache.signature && + layoutCache.nodes.length > 0 && + layoutCache.seedMode === seedMode + ) { return layoutCache.nodes } // Compute new layout using force simulation - const computed = computeLayout(nodes, edges, forceConfig, layoutCache.nodes) + const reusePositions = layoutCache.seedMode === seedMode + const computed = computeLayout( + nodes, + edges, + forceConfig, + reusePositions ? layoutCache.nodes : [], + seedMode + ) // Update cache layoutCache.signature = signature layoutCache.nodes = computed + layoutCache.seedMode = seedMode return computed - }, [nodes, edges, forceConfig, useServerPositions]) + }, [nodes, edges, forceConfig, useServerPositions, seedMode]) // Reheat function uses module-level cache const reheat = useCallback(() => { diff --git a/packages/graph-viewer/src/lib/types.ts b/packages/graph-viewer/src/lib/types.ts index bf5c681..f9fdbe4 100644 --- a/packages/graph-viewer/src/lib/types.ts +++ b/packages/graph-viewer/src/lib/types.ts @@ -184,7 +184,7 @@ export const DEFAULT_DISPLAY_CONFIG: DisplayConfig = { } export const DEFAULT_CLUSTER_CONFIG: ClusterConfig = { - mode: 'tags', + mode: 'none', showBoundaries: true, clusterStrength: 0.3, useUMAP: false, diff --git a/packages/graph-viewer/vite.config.ts b/packages/graph-viewer/vite.config.ts index 0029998..5e76e4a 100644 --- a/packages/graph-viewer/vite.config.ts +++ b/packages/graph-viewer/vite.config.ts @@ -4,11 +4,16 @@ import path from 'path' export default defineConfig({ plugins: [react()], - // Base path for embedded mode - assets served from /viewer/static/ - base: '/viewer/static/', + // Use VITE_BASE for deployment, default to /viewer/static/ for local dev + base: process.env.VITE_BASE || '/viewer/static/', resolve: { alias: { '@': path.resolve(__dirname, './src'), + // IMPORTANT: Force a single Three.js instance. + // @react-three/xr -> @pmndrs/xr currently pulls in @iwer/devui/@iwer/sem which depend on three@0.165.0, + // which triggers: "WARNING: Multiple instances of Three.js being imported." + // This alias ensures all imports resolve to the top-level three dependency. + three: path.resolve(__dirname, './node_modules/three'), }, }, server: { From 7d466a57f8a04e5695f870423812cd2868b72111 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Tue, 6 Jan 2026 23:31:59 +0100 Subject: [PATCH 46/47] feat: Add VITE_API_TARGET env support for API base URL Introduces support for the VITE_API_TARGET environment variable to configure the API base URL in both the API client and TokenPrompt component. This allows deployments to specify the API endpoint via environment configuration, improving flexibility. --- packages/graph-viewer/src/api/client.ts | 13 +++++++++++++ .../graph-viewer/src/components/TokenPrompt.tsx | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/graph-viewer/src/api/client.ts b/packages/graph-viewer/src/api/client.ts index 84fd4c2..0bf56ca 100644 --- a/packages/graph-viewer/src/api/client.ts +++ b/packages/graph-viewer/src/api/client.ts @@ -8,6 +8,14 @@ function isEmbeddedMode(): boolean { return window.location.pathname.startsWith('/viewer') } +function getEnvApiTarget(): string | null { + const raw = import.meta.env.VITE_API_TARGET + if (!raw) return null + const trimmed = raw.trim() + if (!trimmed) return null + return trimmed.replace(/\/+$/, '') +} + /** * Get token from URL hash (e.g., /viewer/#token=xxx). * This keeps the token client-side only, never sent to server in URL. @@ -27,6 +35,11 @@ function getApiBase(): string { return serverOverride } + const envTarget = getEnvApiTarget() + if (envTarget) { + return envTarget + } + if (isEmbeddedMode()) { // In embedded mode, use relative URL (same origin) return '' diff --git a/packages/graph-viewer/src/components/TokenPrompt.tsx b/packages/graph-viewer/src/components/TokenPrompt.tsx index 82fa6ca..7b5087f 100644 --- a/packages/graph-viewer/src/components/TokenPrompt.tsx +++ b/packages/graph-viewer/src/components/TokenPrompt.tsx @@ -7,7 +7,10 @@ interface TokenPromptProps { } export function TokenPrompt({ onSubmit }: TokenPromptProps) { - const [serverUrl, setServerUrl] = useState('https://automem.up.railway.app') + const defaultServerUrl = + (import.meta.env.VITE_API_TARGET && import.meta.env.VITE_API_TARGET.trim()) || + 'https://automem.up.railway.app' + const [serverUrl, setServerUrl] = useState(defaultServerUrl) const [token, setToken] = useState('') const [isValidating, setIsValidating] = useState(false) const [error, setError] = useState(null) From 98ce6f4c3aa688e7613484895f9544f4ccebc85e Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Mon, 19 Jan 2026 14:32:40 +0100 Subject: [PATCH 47/47] feat: Remove focus/spotlight mode UI and improve hand controls Disabled the focus/spotlight mode UI and related logic throughout the Graph Viewer app, including keyboard shortcuts and radial menu options. The 'Enter XR' button now only appears when WebXR is supported in a secure context. Improved bimanual pinch activation and smoothing for pan/zoom/rotate gestures, and updated hand overlays and control badges for clearer feedback. Also fixed the reset view callback wiring so Reset View works again. --- CHANGELOG.md | 5 + packages/graph-viewer/src/App.tsx | 91 +--- .../src/components/GraphCanvas.tsx | 93 ++-- .../src/components/Hand2DOverlay.tsx | 19 +- .../src/components/HandControlOverlay.tsx | 4 +- .../src/components/RadialMenu.tsx | 17 +- .../src/hooks/useHandLockAndGrab.ts | 412 +++++++++--------- .../src/hooks/useKeyboardNavigation.ts | 14 +- 8 files changed, 258 insertions(+), 397 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c482d9..f9637c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ All notable changes to AutoMem will be documented in this file. - Solution: Use Railway TCP Proxy for external access to FalkorDB - Added pre-flight connectivity check with clear error messages and troubleshooting guidance - Updated documentation with TCP Proxy setup instructions +- **Graph Viewer**: + - Disabled focus/spotlight mode UI (feature currently off) + - Only show “Enter XR” when WebXR is supported (secure context + `immersive-vr`) + - Improved bimanual pinch activation and smoothing for pan/zoom/rotate + - Fixed reset view callback wiring so Reset View works again ### Documentation diff --git a/packages/graph-viewer/src/App.tsx b/packages/graph-viewer/src/App.tsx index 48d9d8d..e88f663 100644 --- a/packages/graph-viewer/src/App.tsx +++ b/packages/graph-viewer/src/App.tsx @@ -123,11 +123,9 @@ export default function App() { const [performanceMode, setPerformanceMode] = useState(false) const [settingsPanelOpen, setSettingsPanelOpen] = useState(false) - // Focus/Spotlight mode state - const [focusModeEnabled, setFocusModeEnabled] = useState(false) - const [focusTransition, setFocusTransition] = useState(0) // 0-1 for smooth transition - const focusTransitionRef = useRef(0) - const focusAnimationRef = useRef(null) + // Focus/Spotlight mode is currently disabled + const focusModeEnabled = false + const focusTransition = 0 // Radial menu state const [radialMenuState, setRadialMenuState] = useState<{ @@ -156,15 +154,6 @@ export default function App() { // Tag cloud state const [tagCloudVisible, setTagCloudVisible] = useState(false) - // Cleanup focus animation on unmount - useEffect(() => { - return () => { - if (focusAnimationRef.current) { - cancelAnimationFrame(focusAnimationRef.current) - } - } - }, []) - const [gestureState, setGestureState] = useState(DEFAULT_GESTURE_STATE) // Test mode - check URL param for automated testing @@ -567,44 +556,6 @@ export default function App() { setDisplayConfig(prev => ({ ...prev, showLabels: !prev.showLabels })) }, []) - // Focus mode toggle with smooth transition animation - const handleToggleFocusMode = useCallback(() => { - setFocusModeEnabled(prev => { - const newEnabled = !prev - - // Cancel any existing animation - if (focusAnimationRef.current) { - cancelAnimationFrame(focusAnimationRef.current) - } - - const startTime = performance.now() - const duration = 400 // 400ms transition - const startValue = focusTransitionRef.current - const endValue = newEnabled ? 1 : 0 - - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime - const progress = Math.min(elapsed / duration, 1) - - // Ease out cubic for smooth deceleration - const eased = 1 - Math.pow(1 - progress, 3) - const newTransition = startValue + (endValue - startValue) * eased - - focusTransitionRef.current = newTransition - setFocusTransition(newTransition) - - if (progress < 1) { - focusAnimationRef.current = requestAnimationFrame(animate) - } else { - focusAnimationRef.current = null - } - } - - focusAnimationRef.current = requestAnimationFrame(animate) - return newEnabled - }) - }, []) - // Keyboard navigation const handleStartPathfindingFromKeyboard = useCallback(() => { if (selectedNode) { @@ -617,9 +568,9 @@ export default function App() { selectedNode, onNodeSelect: handleNodeSelect, onReheat: handleReheat, + onResetView: resetViewFn ?? undefined, onToggleSettings: () => setSettingsPanelOpen(prev => !prev), onToggleLabels: handleToggleLabels, - onToggleFocus: handleToggleFocusMode, onSaveBookmark: handleSaveBookmark, onQuickNavigate: handleQuickNavigate, onStartPathfinding: handleStartPathfindingFromKeyboard, @@ -674,34 +625,6 @@ export default function App() { {BUILD_VERSION} - {/* Focus/Spotlight Mode Toggle */} - - {/* Performance Mode Toggle */} )} @@ -663,12 +654,10 @@ function Scene({ // Bimanual navigation: two-hand pinch to pan/zoom/rotate the cloud const wasBimanualRef = useRef(false) - const bimanualAnchorRef = useRef<{ + const bimanualSmoothedRef = useRef<{ distance: number angle: number center: { x: number; y: number } - worldPos: { x: number; y: number; z: number } - worldRotZ: number } | null>(null) // Direct pinch selection ("pick the berry") @@ -693,9 +682,9 @@ function Scene({ // --- Bimanual pinch: two-point transform (pan/zoom/rotate) --- if (bimanualPinch && leftMetrics && rightMetrics) { - const PAN_SPEED = 350 // world units per normalized screen unit - const ZOOM_SPEED = 320 // world units per ln(distance ratio) - const ROTATE_SPEED = 1.0 // radians per radian of pinch-line rotation + const PAN_SPEED = 420 // world units per normalized screen unit + const ZOOM_SPEED = 360 // world units per ln(distance ratio) + const ROTATE_SPEED = 1.1 // radians per radian of pinch-line rotation const left = leftMetrics.pinchPoint const right = rightMetrics.pinchPoint @@ -723,46 +712,52 @@ function Scene({ const angle = canonicalSegmentAngle(Math.atan2(dyUp, dx)) - if (!wasBimanualRef.current) { - bimanualAnchorRef.current = { - distance: Math.max(1e-4, distance), - angle, - center, - worldPos: { x: group.position.x, y: group.position.y, z: group.position.z }, - worldRotZ: group.rotation.z, - } + const safeDt = Math.max(1e-4, dt) + const smoothing = 1 - Math.exp(-20 * safeDt) + const follow = 1 - Math.exp(-55 * safeDt) + + const prevSmooth = + wasBimanualRef.current && bimanualSmoothedRef.current + ? bimanualSmoothedRef.current + : { center, distance: Math.max(1e-4, distance), angle } + + const nextSmooth = { + center: { + x: THREE.MathUtils.lerp(prevSmooth.center.x, center.x, smoothing), + y: THREE.MathUtils.lerp(prevSmooth.center.y, center.y, smoothing), + }, + distance: THREE.MathUtils.lerp(prevSmooth.distance, Math.max(1e-4, distance), smoothing), + angle: prevSmooth.angle + normalizeDeltaPi(angle - prevSmooth.angle) * smoothing, } - const anchor = bimanualAnchorRef.current - if (anchor) { - const safeDt = Math.max(1e-4, dt) - const follow = 1 - Math.exp(-18 * safeDt) + // First frame: initialize smoothing state but don't apply a jump. + if (!wasBimanualRef.current) { + bimanualSmoothedRef.current = nextSmooth + wasBimanualRef.current = true + wasGrabbingRef.current = false + return + } - const panDx = center.x - anchor.center.x - const panDy = center.y - anchor.center.y - const rotationDelta = normalizeDeltaPi(angle - anchor.angle) + bimanualSmoothedRef.current = nextSmooth - // Standard pinch zoom uses a distance ratio. log() makes it symmetric for in/out. - const distRatio = Math.max(1e-4, distance) / Math.max(1e-4, anchor.distance) - const zoomDelta = Math.log(distRatio) + const panDx = nextSmooth.center.x - prevSmooth.center.x + const panDy = nextSmooth.center.y - prevSmooth.center.y + const rotationDelta = normalizeDeltaPi(nextSmooth.angle - prevSmooth.angle) - const targetX = anchor.worldPos.x + panDx * PAN_SPEED - const targetY = anchor.worldPos.y - panDy * PAN_SPEED - const targetZ = anchor.worldPos.z + zoomDelta * ZOOM_SPEED - const targetRotZ = anchor.worldRotZ + rotationDelta * ROTATE_SPEED + const distRatio = Math.max(1e-4, nextSmooth.distance) / Math.max(1e-4, prevSmooth.distance) + const zoomDelta = Math.log(distRatio) - group.position.x = THREE.MathUtils.lerp(group.position.x, targetX, follow) - group.position.y = THREE.MathUtils.lerp(group.position.y, targetY, follow) - group.position.z = THREE.MathUtils.lerp(group.position.z, targetZ, follow) - group.rotation.z = THREE.MathUtils.lerp(group.rotation.z, targetRotZ, follow) - } + group.position.x += panDx * PAN_SPEED * follow + group.position.y -= panDy * PAN_SPEED * follow + group.position.z += zoomDelta * ZOOM_SPEED * follow + group.rotation.z += rotationDelta * ROTATE_SPEED * follow wasBimanualRef.current = true wasGrabbingRef.current = false return } else { wasBimanualRef.current = false - bimanualAnchorRef.current = null + bimanualSmoothedRef.current = null } // --- Grab: follow target with damping + inertial coast on release --- diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx index 5069660..43c1f28 100644 --- a/packages/graph-viewer/src/components/Hand2DOverlay.tsx +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -145,19 +145,8 @@ export function Hand2DOverlay({ if (!enabled || !gestureState.isTracking) return null // Visibility gating: - // - Non-acquired hands should be *extremely* faint to avoid the "desk hand blur" problem. - // - Locked hand is bright. - const lockMode = lock?.mode ?? 'idle' - const lockedHand = - lock?.mode === 'locked' - ? lock.hand - : lock?.mode === 'candidate' - ? lock.hand - : null - const baseOpacity = - lockMode === 'locked' ? 0.85 : - lockMode === 'candidate' ? 0.25 : - 0.06 + // Keep both hands visually consistent; only boost opacity when the user is actively interacting. + const baseOpacity = lock?.mode === 'locked' ? 0.85 : 0.18 return (
@@ -209,7 +198,7 @@ export function Hand2DOverlay({ color="#4ecdc4" gradientId="hand-gradient-cyan" isGhost={leftSmoothed.isGhost} - opacityMultiplier={baseOpacity * (lockedHand && lockedHand !== 'left' ? 0.08 : 1)} + opacityMultiplier={baseOpacity} /> )} @@ -222,7 +211,7 @@ export function Hand2DOverlay({ color="#f72585" gradientId="hand-gradient-magenta" isGhost={rightSmoothed.isGhost} - opacityMultiplier={baseOpacity * (lockedHand && lockedHand !== 'right' ? 0.08 : 1)} + opacityMultiplier={baseOpacity} /> )} diff --git a/packages/graph-viewer/src/components/HandControlOverlay.tsx b/packages/graph-viewer/src/components/HandControlOverlay.tsx index 718413d..1a806c7 100644 --- a/packages/graph-viewer/src/components/HandControlOverlay.tsx +++ b/packages/graph-viewer/src/components/HandControlOverlay.tsx @@ -32,8 +32,8 @@ export function HandControlOverlay({ const badge = lock.mode === 'locked' ? lock.grabbed - ? { text: 'GRABBED', color: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30' } - : { text: 'LOCKED', color: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30' } + ? { text: `GRABBING (${lock.hand.toUpperCase()})`, color: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30' } + : { text: `ACTIVE (${lock.hand.toUpperCase()})`, color: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30' } : lock.mode === 'candidate' ? { text: `ACQUIRING (${lock.frames})`, color: 'bg-yellow-500/20 text-yellow-200 border-yellow-400/30' } : { text: 'IDLE', color: 'bg-slate-500/20 text-slate-200 border-slate-400/30' } diff --git a/packages/graph-viewer/src/components/RadialMenu.tsx b/packages/graph-viewer/src/components/RadialMenu.tsx index bf0337a..612147d 100644 --- a/packages/graph-viewer/src/components/RadialMenu.tsx +++ b/packages/graph-viewer/src/components/RadialMenu.tsx @@ -12,7 +12,6 @@ import { useEffect, useCallback, useState } from 'react' import { Search, - Sun, Route, Plus, Pencil, @@ -37,14 +36,12 @@ interface RadialMenuProps { position: { x: number; y: number } onClose: () => void onFindSimilar?: (node: GraphNode) => void - onToggleFocus?: () => void onStartPath?: (nodeId: string) => void onAddToSelection?: (node: GraphNode) => void onEdit?: (node: GraphNode) => void onViewContent?: (node: GraphNode) => void onCopyId?: (nodeId: string) => void onDelete?: (node: GraphNode) => void - focusModeEnabled?: boolean } export function RadialMenu({ @@ -52,14 +49,12 @@ export function RadialMenu({ position, onClose, onFindSimilar, - onToggleFocus, onStartPath, onAddToSelection, onEdit, onViewContent, onCopyId, onDelete, - focusModeEnabled = false, }: RadialMenuProps) { const [isOpen, setIsOpen] = useState(false) const [hoveredItem, setHoveredItem] = useState(null) @@ -98,7 +93,7 @@ export function RadialMenu({ onClose() }, [node.id, onCopyId, onClose]) - // Menu items arranged in a circle (8 positions, starting from top) + // Menu items arranged in a circle (positions, starting from top) const menuItems: RadialMenuItem[] = [ { id: 'find-similar', @@ -110,16 +105,6 @@ export function RadialMenu({ onClose() }, }, - { - id: 'focus', - icon: , - label: focusModeEnabled ? 'Exit Focus' : 'Focus Mode', - color: focusModeEnabled ? 'from-amber-500 to-yellow-500' : 'from-amber-400 to-orange-500', - action: () => { - onToggleFocus?.() - onClose() - }, - }, { id: 'start-path', icon: , diff --git a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts index 13c579c..62d5ce2 100644 --- a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts +++ b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts @@ -1,18 +1,15 @@ /** - * Hand Lock + Grab State Machine + * Hand Grab + Pinch Control (no explicit locking) * - * Goal: Make gestures intentional. - * - Hand is ignored until user presents an "open palm + spread fingers" pose (acquire). - * - Once acquired, we maintain a lock for a short time even through partial landmark loss. - * - In locked state, a closed fist ("grab") manipulates the cloud: - * - Pull toward body => zoom in (exponential response) - * - Push toward screen => zoom out (exponential response) - * - Move fist around screen => rotate cloud + * Goal: Make gestures feel direct. + * - No acquire/lock workflow; either hand can grab/pinch immediately. + * - Closed fist ("grab") manipulates the cloud via displacement deltas. + * - Pinch drives direct selection (hover + click via hysteresis). * * Works with either MediaPipe or iPhone-fed landmarks because it only needs GestureState. */ -import { useMemo, useRef } from 'react' +import { useRef } from 'react' import type { GestureState } from './useHandGestures' type HandSide = 'left' | 'right' @@ -72,10 +69,6 @@ export interface CloudControlDeltas { const DEFAULT_CONFIDENCE = 0.7 // Tunables (these matter a lot for UX) -const ACQUIRE_FRAMES_REQUIRED = 8 -const LOCK_PERSIST_MS = 2000 // 2 seconds before unlocking when hand leaves frame - -// Make acquisition VERY intentional: open palm + spread fingers + palm facing camera. const SPREAD_THRESHOLD = 0.78 const PALM_FACING_THRESHOLD = 0.72 @@ -86,9 +79,14 @@ const GRAB_OFF_THRESHOLD = 0.45 const PINCH_ON_THRESHOLD = 0.85 const PINCH_OFF_THRESHOLD = 0.65 +// Pinch mode (hover) thresholds: allow hover while "half pinched" +const PINCH_MODE_ON_THRESHOLD = 0.35 +const PINCH_MODE_OFF_THRESHOLD = 0.25 + // Two-hand navigation: both hands pinching -const BIMANUAL_PINCH_ON_THRESHOLD = 0.75 -const BIMANUAL_PINCH_OFF_THRESHOLD = 0.55 +const BIMANUAL_PINCH_ON_THRESHOLD = 0.55 +const BIMANUAL_PINCH_OFF_THRESHOLD = 0.35 +const BIMANUAL_PINCH_GRACE_MS = 220 // Clear selection: hold open palm for ~0.5 seconds const CLEAR_FRAMES_REQUIRED = 30 @@ -269,227 +267,207 @@ export interface HandLockResult { export function useHandLockAndGrab(state: GestureState, enabled: boolean): HandLockResult { const lockRef = useRef({ mode: 'idle', metrics: null }) const bimanualPinchRef = useRef(false) + const bimanualLastGoodMsRef = useRef(0) + const grabRef = useRef<{ left: boolean; right: boolean }>({ left: false, right: false }) + const pinchModeRef = useRef<{ left: boolean; right: boolean }>({ left: false, right: false }) + const pinchActivatedRef = useRef<{ left: boolean; right: boolean }>({ left: false, right: false }) + const clearHoldFramesRef = useRef(0) const nowMs = performance.now() const right = enabled ? computeMetrics(state, 'right') : null const left = enabled ? computeMetrics(state, 'left') : null - // Bimanual pinch: both hands are pinching simultaneously (with hysteresis) - const bimanualPinch = - enabled && left && right - ? bimanualPinchRef.current - ? left.pinch >= BIMANUAL_PINCH_OFF_THRESHOLD && right.pinch >= BIMANUAL_PINCH_OFF_THRESHOLD - : left.pinch >= BIMANUAL_PINCH_ON_THRESHOLD && right.pinch >= BIMANUAL_PINCH_ON_THRESHOLD - : false - bimanualPinchRef.current = bimanualPinch + // Bimanual pinch: both hands pinching, with hysteresis + short grace to tolerate brief signal drops. + let bimanualPinch = false + if (enabled && left && right) { + const bothOn = left.pinch >= BIMANUAL_PINCH_ON_THRESHOLD && right.pinch >= BIMANUAL_PINCH_ON_THRESHOLD + const bothOff = left.pinch >= BIMANUAL_PINCH_OFF_THRESHOLD && right.pinch >= BIMANUAL_PINCH_OFF_THRESHOLD - // Choose a hand to consider when not locked: - // - Prefer a hand currently holding the acquire pose - // - Otherwise prefer right if present, else left - const rightAcquire = !!right && isAcquirePose(right) - const leftAcquire = !!left && isAcquirePose(left) - const chosenHand: HandSide | null = - rightAcquire ? 'right' : leftAcquire ? 'left' : right ? 'right' : left ? 'left' : null - const metrics = chosenHand === 'right' ? right : chosenHand === 'left' ? left : null - - const next = useMemo((): { lock: HandLockState; deltas: CloudControlDeltas; clearRequested: boolean } => { - const noDeltas = { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } - - if (!enabled) { - lockRef.current = { mode: 'idle', metrics: null } - return { lock: lockRef.current, deltas: noDeltas, clearRequested: false } + if (!bimanualPinchRef.current) { + if (bothOn) { + bimanualPinch = true + bimanualLastGoodMsRef.current = nowMs + } + } else { + if (bothOff) { + bimanualPinch = true + bimanualLastGoodMsRef.current = nowMs + } else if (nowMs - bimanualLastGoodMsRef.current <= BIMANUAL_PINCH_GRACE_MS) { + bimanualPinch = true + } } + } else { + bimanualLastGoodMsRef.current = 0 + } - const prev = lockRef.current - - // Locked mode: ONLY the locked hand can drive state; never switch to the other hand implicitly. - if (prev.mode === 'locked') { - const lockedMetrics = prev.hand === 'right' ? right : left - const handData = prev.hand === 'right' ? state.rightHand : state.leftHand - - // Locked hand not currently seen - if (!lockedMetrics || !handData) { - // Persist lock briefly, but don't let another hand keep it alive. - if (nowMs - prev.lastSeenMs <= LOCK_PERSIST_MS) { - const persisted: HandLockState = { ...prev, metrics: prev.metrics } - lockRef.current = persisted - return { lock: persisted, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - } - lockRef.current = { mode: 'idle', metrics: null } - return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - } + bimanualPinchRef.current = bimanualPinch - const wrist = handData.landmarks[0] - const x = wrist?.x ?? prev.neutral.x - const y = wrist?.y ?? prev.neutral.y - - // Grab hysteresis - const grabbed = - prev.grabbed - ? lockedMetrics.grab >= GRAB_OFF_THRESHOLD - : lockedMetrics.grab >= GRAB_ON_THRESHOLD - - // Pinch hysteresis for selection ("pick the berry") - // Only activate when NOT grabbing (grab takes priority) - const pinchActivated = grabbed - ? false - : prev.pinchActivated - ? lockedMetrics.pinch >= PINCH_OFF_THRESHOLD - : lockedMetrics.pinch >= PINCH_ON_THRESHOLD - - // Clear selection: hold acquire pose for ~0.5 seconds while locked - // Only track when NOT grabbing and NOT pinching (intentional open palm) - const isHoldingAcquirePose = isAcquirePose(lockedMetrics) - const clearHoldFrames = - !grabbed && !pinchActivated && isHoldingAcquirePose - ? (prev.clearHoldFrames ?? 0) + 1 - : 0 - const clearRequested = clearHoldFrames >= CLEAR_FRAMES_REQUIRED - - const lock: HandLockState = { - ...prev, - metrics: lockedMetrics, - grabbed, - pinchActivated, - lastSeenMs: nowMs, - clearHoldFrames, - } + const noDeltas: CloudControlDeltas = { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } - const deltas: CloudControlDeltas = { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } - - if (grabbed) { - const isFirstGrabFrame = !prev.grabbed - - if (isFirstGrabFrame) { - // First frame of grab - set anchor and signal to capture world position - lock.grabAnchor = { x, y, depth: lockedMetrics.depth } - deltas.grabStart = true - lockRef.current = lock - return { lock, deltas, clearRequested: false } - } - - const anchor = prev.grabAnchor ?? { x, y, depth: lockedMetrics.depth } - - // Calculate displacement from anchor (how far hand moved since grab started) - const dx = x - anchor.x // hand moved right in screen space (0-1 normalized) - const dy = y - anchor.y // hand moved down in screen space - const dz = lockedMetrics.depth - anchor.depth // hand moved toward/away from camera - - // PAN the world: displacement-based, not velocity - // Scale: moving hand across half the screen (~0.5) should move graph ~150 world units - // That's a reasonable "arm's reach" mapping - const PAN_GAIN = 300 // world units per full screen unit of hand movement - - deltas.panX = dx * PAN_GAIN // drag right = world moves right - deltas.panY = dy * PAN_GAIN // drag down = world moves down (Y is flipped in screen coords) - - // Depth -> Z translation - // Moving hand ~0.2 depth units should translate maybe 50-100 world units - const DEPTH_PAN_GAIN = 250 - deltas.panZ = dz * DEPTH_PAN_GAIN - - // Also apply zoom based on depth (optional, can remove if too much) - if (Math.abs(dz) > DEPTH_DEADZONE) { - deltas.zoom = dz * 0.5 // gentle zoom - } - } else { - lock.grabAnchor = undefined - } + if (!enabled) { + lockRef.current = { mode: 'idle', metrics: null } + grabRef.current = { left: false, right: false } + pinchModeRef.current = { left: false, right: false } + pinchActivatedRef.current = { left: false, right: false } + clearHoldFramesRef.current = 0 + return { lock: lockRef.current, deltas: noDeltas, clearRequested: false, bimanualPinch, leftMetrics: left, rightMetrics: right } + } - lockRef.current = lock - return { lock, deltas, clearRequested } - } + // --- Per-hand hysteresis state --- + const nextGrabLeft = + !!left && + (grabRef.current.left ? left.grab >= GRAB_OFF_THRESHOLD : left.grab >= GRAB_ON_THRESHOLD) + const nextGrabRight = + !!right && + (grabRef.current.right ? right.grab >= GRAB_OFF_THRESHOLD : right.grab >= GRAB_ON_THRESHOLD) + grabRef.current = { left: nextGrabLeft, right: nextGrabRight } + + const nextPinchModeLeft = + !bimanualPinch && + !!left && + !nextGrabLeft && + (pinchModeRef.current.left ? left.pinch >= PINCH_MODE_OFF_THRESHOLD : left.pinch >= PINCH_MODE_ON_THRESHOLD) + const nextPinchModeRight = + !bimanualPinch && + !!right && + !nextGrabRight && + (pinchModeRef.current.right ? right.pinch >= PINCH_MODE_OFF_THRESHOLD : right.pinch >= PINCH_MODE_ON_THRESHOLD) + pinchModeRef.current = { left: nextPinchModeLeft, right: nextPinchModeRight } + + const nextPinchActivatedLeft = + !bimanualPinch && + !!left && + !nextGrabLeft && + (pinchActivatedRef.current.left ? left.pinch >= PINCH_OFF_THRESHOLD : left.pinch >= PINCH_ON_THRESHOLD) + const nextPinchActivatedRight = + !bimanualPinch && + !!right && + !nextGrabRight && + (pinchActivatedRef.current.right ? right.pinch >= PINCH_OFF_THRESHOLD : right.pinch >= PINCH_ON_THRESHOLD) + pinchActivatedRef.current = { left: nextPinchActivatedLeft, right: nextPinchActivatedRight } + + // Choose active hand for single-hand interactions (grab > pinch > present) + const choosePreferredHand = (a: HandSide, b: HandSide) => { + // Prefer the hand with stronger intent signal; break ties to right for stability. + const aMetrics = a === 'left' ? left : right + const bMetrics = b === 'left' ? left : right + if (!aMetrics) return b + if (!bMetrics) return a + + const aGrab = a === 'left' ? nextGrabLeft : nextGrabRight + const bGrab = b === 'left' ? nextGrabLeft : nextGrabRight + if (aGrab !== bGrab) return aGrab ? a : b + + const aPinchMode = a === 'left' ? nextPinchModeLeft : nextPinchModeRight + const bPinchMode = b === 'left' ? nextPinchModeLeft : nextPinchModeRight + if (aPinchMode !== bPinchMode) return aPinchMode ? a : b + + const aScore = Math.max(aMetrics.grab, aMetrics.pinch) + const bScore = Math.max(bMetrics.grab, bMetrics.pinch) + if (Math.abs(aScore - bScore) > 0.05) return aScore > bScore ? a : b + + return 'right' + } - // No hand seen - if (!chosenHand || !metrics) { - lockRef.current = { mode: 'idle', metrics: null } - return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - } + let activeHand: HandSide | null = null + if (!bimanualPinch && (nextGrabLeft || nextGrabRight)) { + activeHand = nextGrabLeft && nextGrabRight ? choosePreferredHand('left', 'right') : nextGrabLeft ? 'left' : 'right' + } else if (!bimanualPinch && (nextPinchModeLeft || nextPinchModeRight)) { + activeHand = nextPinchModeLeft && nextPinchModeRight ? choosePreferredHand('left', 'right') : nextPinchModeLeft ? 'left' : 'right' + } else { + activeHand = right ? 'right' : left ? 'left' : null + } - // Hand seen: update FSM - if (prev.mode === 'idle') { - if (isAcquirePose(metrics)) { - const candidate: HandLockState = { mode: 'candidate', hand: chosenHand, metrics, frames: 1 } - lockRef.current = candidate - return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - } - const idle: HandLockState = { mode: 'idle', metrics } - lockRef.current = idle - return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - } + // Clear selection: hold acquire pose (open palm + spread + palm facing) for ~0.5s + const anyAcquirePose = (!!left && isAcquirePose(left)) || (!!right && isAcquirePose(right)) + clearHoldFramesRef.current = anyAcquirePose ? clearHoldFramesRef.current + 1 : 0 + const clearRequested = clearHoldFramesRef.current >= CLEAR_FRAMES_REQUIRED - if (prev.mode === 'candidate') { - const candidateHand = prev.hand - const candidateMetrics = candidateHand === 'right' ? right : left - if (candidateMetrics && isAcquirePose(candidateMetrics)) { - const frames = prev.frames + 1 - if (frames >= ACQUIRE_FRAMES_REQUIRED) { - // lock! - const handData = candidateHand === 'right' ? state.rightHand : state.leftHand - const wrist = handData?.landmarks[0] - const locked: HandLockState = { - mode: 'locked', - hand: candidateHand, - metrics: candidateMetrics, - lockedAtMs: nowMs, - neutral: { x: wrist?.x ?? 0.5, y: wrist?.y ?? 0.5, depth: candidateMetrics.depth }, - grabbed: false, - pinchActivated: false, - lastSeenMs: nowMs, - clearHoldFrames: 0, - } - lockRef.current = locked - return { lock: locked, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - } - const candidate: HandLockState = { mode: 'candidate', hand: candidateHand, metrics: candidateMetrics, frames } - lockRef.current = candidate - return { lock: candidate, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - } - // lost candidate - const idle: HandLockState = { mode: 'idle', metrics } - lockRef.current = idle - return { lock: idle, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } + if (!activeHand) { + lockRef.current = { mode: 'idle', metrics: null } + return { lock: lockRef.current, deltas: noDeltas, clearRequested, bimanualPinch, leftMetrics: left, rightMetrics: right } + } + + const activeMetrics = activeHand === 'left' ? left : right + const activeHandData = activeHand === 'left' ? state.leftHand : state.rightHand + + if (!activeMetrics || !activeHandData) { + lockRef.current = { mode: 'idle', metrics: activeMetrics ?? null } + return { lock: lockRef.current, deltas: noDeltas, clearRequested, bimanualPinch, leftMetrics: left, rightMetrics: right } + } + + const wrist = activeHandData.landmarks[0] + const x = wrist?.x ?? 0.5 + const y = wrist?.y ?? 0.5 + + const grabbed = activeHand === 'left' ? nextGrabLeft : nextGrabRight + const pinchMode = activeHand === 'left' ? nextPinchModeLeft : nextPinchModeRight + const pinchActivated = grabbed ? false : activeHand === 'left' ? nextPinchActivatedLeft : nextPinchActivatedRight + + // Expose a single "active" state for consumers (GraphCanvas / overlays). + // We reuse the historical `mode: 'locked'` variant to avoid widespread type churn. + const prev = lockRef.current + const wasGrabbed = prev.mode === 'locked' && prev.hand === activeHand ? prev.grabbed : false + const isActive = grabbed || pinchMode + + if (!isActive) { + lockRef.current = { mode: 'idle', metrics: activeMetrics } + return { lock: lockRef.current, deltas: noDeltas, clearRequested, bimanualPinch, leftMetrics: left, rightMetrics: right } + } + + const deltas: CloudControlDeltas = { ...noDeltas } + let grabAnchor: { x: number; y: number; depth: number } | undefined + + if (grabbed) { + const isFirstGrabFrame = !wasGrabbed + const prevAnchor = + prev.mode === 'locked' && prev.hand === activeHand ? prev.grabAnchor : undefined + grabAnchor = isFirstGrabFrame ? { x, y, depth: activeMetrics.depth } : prevAnchor ?? { x, y, depth: activeMetrics.depth } + deltas.grabStart = isFirstGrabFrame + + // Calculate displacement from anchor (how far hand moved since grab started) + const dx = x - grabAnchor.x + const dy = y - grabAnchor.y + const dz = activeMetrics.depth - grabAnchor.depth + + // PAN the world: displacement-based, not velocity + const PAN_GAIN = 300 + deltas.panX = dx * PAN_GAIN + deltas.panY = dy * PAN_GAIN + + // Depth -> Z translation + const DEPTH_PAN_GAIN = 250 + deltas.panZ = dz * DEPTH_PAN_GAIN + + // Gentle zoom based on depth + if (Math.abs(dz) > DEPTH_DEADZONE) { + deltas.zoom = dz * 0.5 } + } + + const locked: HandLockState = { + mode: 'locked', + hand: activeHand, + metrics: activeMetrics, + lockedAtMs: prev.mode === 'locked' && prev.hand === activeHand ? prev.lockedAtMs : nowMs, + neutral: + prev.mode === 'locked' && prev.hand === activeHand + ? prev.neutral + : { x, y, depth: activeMetrics.depth }, + grabbed, + grabAnchor: grabbed ? grabAnchor : undefined, + lastSeenMs: nowMs, + pinchActivated, + clearHoldFrames: clearHoldFramesRef.current, + } - lockRef.current = { mode: 'idle', metrics } - return { lock: lockRef.current, deltas: { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false }, clearRequested: false } - }, [ - enabled, - chosenHand, - // metrics is a new object each render; depend on its fields instead - metrics?.spread, - metrics?.palmFacing, - metrics?.point, - metrics?.pinch, - metrics?.grab, - metrics?.depth, - metrics?.confidence, - nowMs, - state.leftHand, - state.rightHand, - state.handsDetected, - state.grabStrength, - state.leftPinchRay, - state.rightPinchRay, - right?.spread, - right?.palmFacing, - right?.point, - right?.pinch, - right?.grab, - right?.depth, - right?.confidence, - left?.spread, - left?.palmFacing, - left?.point, - left?.pinch, - left?.grab, - left?.depth, - left?.confidence, - ]) + lockRef.current = locked return { - ...next, + lock: lockRef.current, + deltas, + clearRequested, bimanualPinch, leftMetrics: left, rightMetrics: right, diff --git a/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts index 83350f7..c6fbd40 100644 --- a/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts +++ b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts @@ -9,7 +9,6 @@ interface UseKeyboardNavigationOptions { onResetView?: () => void onToggleSettings?: () => void onToggleLabels?: () => void - onToggleFocus?: () => void onSaveBookmark?: () => void onQuickNavigate?: (index: number) => void onStartPathfinding?: () => void @@ -37,7 +36,6 @@ export function useKeyboardNavigation({ onResetView, onToggleSettings, onToggleLabels, - onToggleFocus, onSaveBookmark, onQuickNavigate, onStartPathfinding, @@ -267,14 +265,6 @@ export function useKeyboardNavigation({ } }, }, - f: { - description: 'Toggle focus mode', - action: () => { - if (!event.metaKey && !event.ctrlKey) { - onToggleFocus?.() - } - }, - }, // Help '?': { @@ -291,7 +281,6 @@ export function useKeyboardNavigation({ console.log(' Shift+R: Reset view') console.log(' ,: Toggle settings') console.log(' L: Toggle labels') - console.log(' F: Toggle focus mode') console.log(' Cmd+B: Save bookmark') console.log(' 1-9: Quick navigate to bookmark') }, @@ -303,7 +292,7 @@ export function useKeyboardNavigation({ shortcut.action() } }, - [enabled, findNodeInDirection, navigateSequential, onNodeSelect, onReheat, onResetView, onToggleSettings, onToggleLabels, onToggleFocus, onSaveBookmark, onQuickNavigate, onStartPathfinding, onCancelPathfinding, isPathSelecting] + [enabled, findNodeInDirection, navigateSequential, onNodeSelect, onReheat, onResetView, onToggleSettings, onToggleLabels, onSaveBookmark, onQuickNavigate, onStartPathfinding, onCancelPathfinding, isPathSelecting] ) // Attach event listener @@ -329,7 +318,6 @@ export function useKeyboardNavigation({ { key: 'Shift+R', description: 'Reset view' }, { key: ',', description: 'Toggle settings' }, { key: 'L', description: 'Toggle labels' }, - { key: 'F', description: 'Toggle focus mode' }, { key: 'Cmd+B', description: 'Save bookmark' }, { key: '1-9', description: 'Quick navigate to bookmark' }, { key: '?', description: 'Show help' },