diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..026c69a --- /dev/null +++ b/.flake8 @@ -0,0 +1,12 @@ +[flake8] +max-line-length = 100 +select = E9,F63,F7,F82 +show_source = True +extend-exclude = + .venv, + venv, + node_modules, + packages/graph-viewer/node_modules, + packages/graph-viewer/dist, + packages/graph-viewer/.vite, + tests/benchmarks diff --git a/.gitignore b/.gitignore index 9e32079..bfc5cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,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/AGENTS.md b/AGENTS.md index 26108aa..0cfdf7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,19 @@ - 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/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/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/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 diff --git a/app.py b/app.py index c40b08b..0662fba 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 @@ -111,6 +112,12 @@ def __init__(self, id: str, vector: List[float], payload: Dict[str, Any]): sys.path.insert(0, str(root)) app = Flask(__name__) +CORS( + app, + resources={ + r"/*": {"origins": "*", "allow_headers": ["Content-Type", "X-API-Key", "Authorization"]} + }, +) # Enable CORS for all routes # Legacy blueprint placeholders for deprecated route definitions below. # These are not registered with the app and are safe to keep until full removal. @@ -1142,11 +1149,20 @@ def require_api_token() -> None: if not API_TOKEN: return + # Allow CORS preflight requests (OPTIONS) without auth + if request.method == "OPTIONS": + return + # Allow unauthenticated health checks (supports blueprint endpoint names) endpoint = request.endpoint or "" 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") @@ -3614,6 +3630,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, @@ -3726,6 +3743,12 @@ def get_related_memories(memory_id: str) -> Any: app.register_blueprint(graph_bp) app.register_blueprint(stream_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/graph.py b/automem/api/graph.py index aa6fc60..d7ad136 100644 --- a/automem/api/graph.py +++ b/automem/api/graph.py @@ -5,18 +5,33 @@ from __future__ import annotations -import json import logging import time -from collections import defaultdict -from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Set +import numpy as np from flask import Blueprint, abort, jsonify, request from automem.config import ALLOWED_RELATIONS, MEMORY_TYPES from automem.utils.graph import _serialize_node +# Lazy load UMAP to avoid slow import on every request +_umap_module = None + + +def _get_umap(): + """Lazy load UMAP to avoid slow import at startup.""" + global _umap_module + if _umap_module is None: + try: + import umap + + _umap_module = umap + except ImportError: + _umap_module = False + return _umap_module if _umap_module else None + + logger = logging.getLogger("automem.api.graph") # Color palette for memory types (vibrant, distinguishable in 3D) @@ -70,7 +85,7 @@ def snapshot() -> Any: """ query_start = time.perf_counter() - limit = min(int(request.args.get("limit", 500)), 2000) + limit = min(int(request.args.get("limit", 500)), 5000) min_importance = float(request.args.get("min_importance", 0.0)) types_filter = ( request.args.get("types", "").split(",") if request.args.get("types") else None @@ -203,6 +218,223 @@ def snapshot() -> Any: logger.error(f"graph/snapshot failed: {e}") abort(500, description=str(e)) + @bp.route("/projected", methods=["GET"]) + def projected() -> Any: + """Return graph with UMAP-projected 3D positions based on embeddings. + + Uses dimensionality reduction to position nodes so semantically similar + memories appear close together, regardless of explicit edges or tags. + + Query params: + limit: Max nodes to return (default 500) + min_importance: Filter by minimum importance (0.0-1.0) + types: Comma-separated list of memory types to include + n_neighbors: UMAP n_neighbors param (default 15) + min_dist: UMAP min_dist param (default 0.1) + spread: UMAP spread param (default 1.0) + """ + query_start = time.perf_counter() + + umap_mod = _get_umap() + if umap_mod is None: + abort(501, description="UMAP not available - install umap-learn") + + limit = min(int(request.args.get("limit", 500)), 5000) + min_importance = float(request.args.get("min_importance", 0.0)) + types_filter = ( + request.args.get("types", "").split(",") if request.args.get("types") else None + ) + + # UMAP parameters + n_neighbors = min(int(request.args.get("n_neighbors", 15)), 50) + min_dist = float(request.args.get("min_dist", 0.1)) + spread = float(request.args.get("spread", 1.0)) + + qdrant = get_qdrant_client() + if qdrant is None: + abort(503, description="Vector database unavailable for projection") + + graph = get_memory_graph() + if graph is None: + abort(503, description="Graph database unavailable") + + try: + # Fetch embeddings from Qdrant with scroll + all_points = [] + offset = None + + while True: + scroll_result = qdrant.scroll( + collection_name=collection_name, + limit=100, + offset=offset, + with_vectors=True, + with_payload=True, + ) + points, next_offset = scroll_result + if not points: + break + all_points.extend(points) + if next_offset is None or len(all_points) >= limit: + break + offset = next_offset + + if len(all_points) < 3: + # Not enough points for UMAP + abort(400, description="Need at least 3 memories for projection") + + # Filter by importance and type if specified + filtered_points = [] + for p in all_points: + payload = p.payload or {} + importance = float(payload.get("importance", 0.5)) + mem_type = payload.get("type", "Memory") + + if importance < min_importance: + continue + if types_filter and types_filter[0] and mem_type not in types_filter: + continue + if p.vector is None: + continue + + filtered_points.append(p) + + if len(filtered_points) < 3: + abort(400, description="Not enough memories match filters for projection") + + # Limit to requested count + if len(filtered_points) > limit: + # Sort by importance and take top N + filtered_points.sort( + key=lambda p: float((p.payload or {}).get("importance", 0.5)), + reverse=True, + ) + filtered_points = filtered_points[:limit] + + # Extract embeddings matrix + embeddings = np.array([p.vector for p in filtered_points], dtype=np.float32) + memory_ids = [p.id for p in filtered_points] + + # Run UMAP projection to 3D + logger.info(f"Running UMAP on {len(embeddings)} embeddings...") + umap_start = time.perf_counter() + + reducer = umap_mod.UMAP( + n_components=3, + n_neighbors=min(n_neighbors, len(embeddings) - 1), + min_dist=min_dist, + spread=spread, + metric="cosine", + random_state=42, # Reproducible projections + ) + projected_coords = reducer.fit_transform(embeddings) + + umap_time = time.perf_counter() - umap_start + logger.info(f"UMAP completed in {umap_time:.2f}s") + + # Scale coordinates to reasonable visualization range + # Center at origin and scale to ~100 unit radius + projected_coords -= projected_coords.mean(axis=0) + max_range = np.abs(projected_coords).max() + if max_range > 0: + projected_coords = projected_coords * (100.0 / max_range) + + # Build node list with projected positions + nodes: List[Dict[str, Any]] = [] + node_ids: Set[str] = set() + + for i, point in enumerate(filtered_points): + payload = point.payload or {} + node_id = str(point.id) + node_ids.add(node_id) + + importance = float(payload.get("importance", 0.5)) + confidence = float(payload.get("confidence", 0.8)) + mem_type = payload.get("type", "Memory") + + nodes.append( + { + "id": node_id, + "content": payload.get("content", ""), + "type": mem_type, + "importance": importance, + "confidence": confidence, + "tags": payload.get("tags", []), + "timestamp": payload.get("timestamp"), + "updated_at": payload.get("updated_at"), + "metadata": payload.get("metadata", {}), + # Visual properties + "color": TYPE_COLORS.get(mem_type, TYPE_COLORS["Memory"]), + "radius": 0.5 + (importance * 1.5), + "opacity": 0.4 + (confidence * 0.6), + # UMAP-projected positions + "x": float(projected_coords[i, 0]), + "y": float(projected_coords[i, 1]), + "z": float(projected_coords[i, 2]), + } + ) + + # Fetch edges between visible nodes + edges: List[Dict[str, Any]] = [] + if node_ids: + edge_query = """ + MATCH (m1:Memory)-[r]->(m2:Memory) + WHERE m1.id IN $node_ids AND m2.id IN $node_ids + RETURN m1.id as source, m2.id as target, type(r) as rel_type, r as rel + """ + edge_result = graph.query(edge_query, {"node_ids": list(node_ids)}) + + for row in edge_result.result_set: + source, target, rel_type, rel = row + rel_props = dict(rel.properties) if hasattr(rel, "properties") else {} + strength = float(rel_props.get("strength", 0.5)) + + edges.append( + { + "id": f"{source}-{rel_type}-{target}", + "source": source, + "target": target, + "type": rel_type, + "strength": strength, + "color": RELATION_COLORS.get(rel_type, "#94A3B8"), + "properties": rel_props, + } + ) + + elapsed = time.perf_counter() - query_start + logger.info( + f"graph/projected: {len(nodes)} nodes, {len(edges)} edges in {elapsed:.3f}s" + ) + + return jsonify( + { + "nodes": nodes, + "edges": edges, + "stats": { + "total_nodes": len(all_points), + "returned_nodes": len(nodes), + "returned_edges": len(edges), + }, + "projection": { + "method": "umap", + "n_neighbors": n_neighbors, + "min_dist": min_dist, + "spread": spread, + "dimensions": 3, + "umap_time_ms": round(umap_time * 1000, 2), + }, + "meta": { + "type_colors": TYPE_COLORS, + "relation_colors": RELATION_COLORS, + "query_time_ms": round(elapsed * 1000, 2), + }, + } + ) + + except Exception as e: + logger.error(f"graph/projected failed: {e}") + abort(500, description=str(e)) + @bp.route("/neighbors/", methods=["GET"]) def neighbors(memory_id: str) -> Any: """Get neighbors of a specific memory node. 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/.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/IMPLEMENTATION_PLAN.md b/packages/graph-viewer/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..8c22613 --- /dev/null +++ b/packages/graph-viewer/IMPLEMENTATION_PLAN.md @@ -0,0 +1,200 @@ +# Graph Visualization Clustering - Phase 2 Implementation Plan + +## Current State (Phase 1 Complete) + +Quick wins implemented in `3dffb49`: +- Tag-based initial positioning (nodes with same tag start clustered) +- Entity tag prioritization (`entity:*` tags preferred for clustering) +- Tighter force physics (chargeStrength -60, linkStrength 0.75, linkDistance 40) +- Semantic clustering threshold lowered to 0.3 + +**Result**: Clusters now visually contain their nodes instead of spanning the entire graph. + +--- + +## Phase 2: UMAP-Based Embedding Positioning + +### The Problem +Current clustering uses tags and explicit edges. Memories with similar *content* but no shared tags/edges still scatter. True semantic similarity from embeddings isn't used for positioning. + +### Solution Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Qdrant │────▶│ UMAP Projection │────▶│ Initial (x,y,z)│ +│ 768d vectors │ │ 768d → 3d │ │ positions │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ D3 Force Sim │ + │ (refinement) │ + └─────────────────┘ +``` + +### Implementation Options + +#### Option A: Server-Side UMAP (Recommended) +- Add `/graph/umap` endpoint to Flask API +- Use `umap-learn` Python package +- Pre-compute and cache projections +- Return 3D positions with graph data + +**Pros**: Fast on client, leverages existing Qdrant access, cacheable +**Cons**: Requires API change, server compute cost + +#### Option B: Client-Side UMAP +- Fetch embeddings from Qdrant via API +- Run UMAP in browser with umap-js +- Cache results in localStorage + +**Pros**: No backend changes, works offline +**Cons**: Slow for 1000+ nodes, requires embedding fetch + +#### Option C: Hybrid t-SNE/UMAP +- Server pre-computes global layout +- Client refines with force simulation +- Periodic background recomputation + +### Recommended Implementation (Option A) + +1. **New API Endpoint**: `GET /graph/projected` + ```python + @app.route('/graph/projected') + def graph_projected(): + # Get memories with embeddings from Qdrant + # Run UMAP(n_components=3, n_neighbors=15, min_dist=0.1) + # Return nodes with (x, y, z) positions + return { + "nodes": [...], # with x,y,z from UMAP + "edges": [...], + "projection": { + "method": "umap", + "n_neighbors": 15, + "min_dist": 0.1, + "cached_at": "2025-12-28T..." + } + } + ``` + +2. **Cache Strategy**: + - Recompute when memory count changes by >10% + - Store projections in FalkorDB node properties + - Background job during consolidation + +3. **Client Changes**: + - New `fetchProjectedGraph()` in api.ts + - Skip force simulation initial phase if UMAP positions present + - Fallback to tag-based positioning if no projection + +--- + +## Phase 3: Multi-Agent Architecture + +### Agent Responsibilities + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Orchestrator Agent │ +│ Coordinates tasks, manages shared state, routes requests │ +└─────────────────────────────────────────────────────────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Testing │ │ Storage │ │ Retrieval│ │ Perf │ │ Visual │ +│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +#### Testing Agent +- Owns: `tests/`, Playwright tests, visual regression +- Responsibilities: + - Unit tests for UMAP projection + - Integration tests for API changes + - Visual snapshot testing for cluster layouts + - Performance benchmarks (time to first meaningful render) + +#### Storage Agent +- Owns: `app.py`, FalkorDB schema, Qdrant collection +- Responsibilities: + - Add projection cache to graph schema + - UMAP projection storage and retrieval + - Embedding dimension migration (768 → 3072 if needed) + - Cache invalidation logic + +#### Retrieval Agent +- Owns: Recall logic, neighbor expansion, semantic search +- Responsibilities: + - Efficient projection fetch for subgraph views + - Incremental projection for new memories + - Neighbor-aware positioning (connected nodes stay close) + +#### Performance Agent +- Owns: Caching, batching, streaming +- Responsibilities: + - UMAP computation optimization (approximate NN, GPU) + - WebGL instancing for 1000+ nodes + - Level-of-detail for zoom levels + - Memory footprint management + +#### Visual Agent +- Owns: `packages/graph-viewer/`, React components, shaders +- Responsibilities: + - UMAP position consumption + - 2D fallback mode (Obsidian-style) + - Cluster boundary rendering + - Interactive pan/zoom/select + +--- + +## Phase 4: 2D Obsidian-Style Mode + +### Design Goals +- "At a glance" overview of memory landscape +- Fast navigation to memory clusters +- Works on mobile/tablet +- Optional 3D depth for power users + +### Implementation +```typescript +// New DisplayConfig option +interface DisplayConfig { + // ...existing + dimensionMode: '2d' | '3d' | 'auto' // auto = 3d on desktop, 2d on mobile +} +``` + +### 2D Layout Changes +- UMAP with `n_components=2` +- Replace Three.js with SVG or Canvas2D +- Obsidian-style node shapes (circles with importance-based size) +- Hover reveals connections with curved bezier edges + +--- + +## Timeline Estimate + +| Phase | Scope | Effort | +|-------|-------|--------| +| Phase 1 | Quick wins (DONE) | ✅ | +| Phase 2A | Server-side UMAP endpoint | 4-6 hours | +| Phase 2B | Client UMAP consumption | 2-3 hours | +| Phase 3 | Multi-agent scaffolding | 2-4 hours | +| Phase 4 | 2D mode | 4-6 hours | + +--- + +## Next Immediate Steps + +1. **Add UMAP to requirements.txt** + ``` + umap-learn>=0.5.5 + ``` + +2. **Create projection endpoint** in `app.py` + +3. **Add projection cache** to FalkorDB Memory nodes + +4. **Update graph-viewer** to consume projected positions + +5. **Add 2D/3D toggle** to settings panel diff --git a/packages/graph-viewer/eslint.config.js b/packages/graph-viewer/eslint.config.js new file mode 100644 index 0000000..c25b195 --- /dev/null +++ b/packages/graph-viewer/eslint.config.js @@ -0,0 +1,45 @@ +import globals from 'globals' +import tseslint from 'typescript-eslint' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', '.vite/**'], + }, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.browser, + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.node, + }, + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': 'off', + }, + } +) 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..112123c --- /dev/null +++ b/packages/graph-viewer/package-lock.json @@ -0,0 +1,6398 @@ +{ + "name": "@automem/graph-viewer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "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", + "@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", + "@react-three/xr": "^6.6.28", + "@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", + "ws": "^8.18.3" + }, + "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/@bufbuild/protobuf": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", + "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, + "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/@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "deprecated": "v0.2.x is no longer supported. Unless you are still using FontAwesome 5, please update to v3.1.1 or greater.", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "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/@iwer/devui": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@iwer/devui/-/devui-1.1.2.tgz", + "integrity": "sha512-ggF1lXSX14BTYP0QzB4xaurySr2PC+3+rtK/dpCR++giWquzFv2mBw3LW/PaCtdl5mqkZMrQ2GSwfUNg9ZoO+w==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-svg-core": "6.6.0", + "@fortawesome/free-solid-svg-icons": "6.6.0", + "@fortawesome/react-fontawesome": "0.2.2", + "@pmndrs/handle": "^6.6.17", + "@pmndrs/pointer-events": "^6.6.17", + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "styled-components": "^6.1.13", + "three": "^0.165.0" + }, + "peerDependencies": { + "iwer": "^2.0.1" + } + }, + "node_modules/@iwer/devui/node_modules/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz", + "integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==", + "license": "MIT" + }, + "node_modules/@iwer/sem": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@iwer/sem/-/sem-0.2.5.tgz", + "integrity": "sha512-vMCfpu/7Qqc+hkBiGD9pxjeObgrhXOrL0KX94CA3yzJaU0dq0y49HXZT6fC+6X/jOmjaM3hjyE1m2h7ZmLzzyA==", + "license": "MIT", + "dependencies": { + "three": "^0.165.0", + "ts-proto": "^2.6.0" + }, + "peerDependencies": { + "iwer": "^2.0.0" + } + }, + "node_modules/@iwer/sem/node_modules/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz", + "integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==", + "license": "MIT" + }, + "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/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", + "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/@pmndrs/handle": { + "version": "6.6.28", + "resolved": "https://registry.npmjs.org/@pmndrs/handle/-/handle-6.6.28.tgz", + "integrity": "sha512-5qlu9PfEN0uEGRUBWAjHEN1zugvt4V0bNRDIAh5uWadVHJg7Obb0T8Rd/3/YOnjP9lM56KEGEG4WCnDAn6ZJ4A==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@pmndrs/pointer-events": "~6.6.28", + "zustand": "^4.5.2" + } + }, + "node_modules/@pmndrs/handle/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/@pmndrs/pointer-events": { + "version": "6.6.28", + "resolved": "https://registry.npmjs.org/@pmndrs/pointer-events/-/pointer-events-6.6.28.tgz", + "integrity": "sha512-kmZlcMsPiTHcotECLFbo6J/qQouX2ZeKNSMRwBWAF/EFesAV1uPwjar6SQDWkDH0fVB1snNbEoWLDLQ84tWOkw==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@pmndrs/xr": { + "version": "6.6.28", + "resolved": "https://registry.npmjs.org/@pmndrs/xr/-/xr-6.6.28.tgz", + "integrity": "sha512-/TV+w+7IWXrjiB/m9ozFfs6EcylGFJ2L4ajFplnz4/9nky77VhLaYcOJm+B67fjqCO69isCZNl8gbrs693aomA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@iwer/devui": "^1.1.1", + "@iwer/sem": "~0.2.5", + "@pmndrs/pointer-events": "~6.6.28", + "iwer": "^2.1.0", + "meshline": "^3.3.1", + "zustand": "^4.5.2" + }, + "peerDependencies": { + "three": "*" + } + }, + "node_modules/@pmndrs/xr/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/@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/@react-three/xr": { + "version": "6.6.28", + "resolved": "https://registry.npmjs.org/@react-three/xr/-/xr-6.6.28.tgz", + "integrity": "sha512-LtybrIpDE6co/FeQr130a1thUdovgqCyz2dy8izsz3PAph3uw+yZR/H+78AIzZ07FNH4xi9tlGHhBlgbbB2sJQ==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@pmndrs/pointer-events": "~6.6.28", + "@pmndrs/xr": "~6.6.28", + "suspend-react": "^0.1.3", + "tunnel-rat": "^0.1.2", + "zustand": "^4.5.2" + }, + "peerDependencies": { + "@react-three/fiber": ">=8", + "react": ">=18", + "react-dom": ">=18", + "three": "*" + } + }, + "node_modules/@react-three/xr/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/@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/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==", + "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/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "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/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "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-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "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/dprint-node": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz", + "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==", + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3" + } + }, + "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/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/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/iwer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/iwer/-/iwer-2.1.1.tgz", + "integrity": "sha512-3VuQhekh/3BMRlaS7FFjcTNjKOwURAgL7mu0HndU72mFNyRyHRpGfhXGZ1iJkjfq/vEw0v1b7fx8k1knGSQ5gQ==", + "license": "MIT", + "dependencies": { + "gl-matrix": "^3.4.3", + "webxr-layers-polyfill": "^1.1.0" + } + }, + "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==", + "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==", + "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==", + "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/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "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==", + "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/styled-components": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.19.tgz", + "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.49", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "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.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==", + "license": "MIT" + }, + "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/ts-poet": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz", + "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==", + "license": "Apache-2.0", + "dependencies": { + "dprint-node": "^1.0.8" + } + }, + "node_modules/ts-proto": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.10.1.tgz", + "integrity": "sha512-4sOE1hCs0uobJgdRCtcEwdbc8MAyKP+LJqUIKxZIiKac0rPBlVKsRGEGo2oQ1MnKA2Wwk0KuGP2POkiCwPtebw==", + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.10.2", + "case-anything": "^2.1.13", + "ts-poet": "^6.12.0", + "ts-proto-descriptors": "2.1.0" + }, + "bin": { + "protoc-gen-ts_proto": "protoc-gen-ts_proto" + } + }, + "node_modules/ts-proto-descriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz", + "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==", + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.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/webxr-layers-polyfill": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/webxr-layers-polyfill/-/webxr-layers-polyfill-1.1.0.tgz", + "integrity": "sha512-GqWE6IFlut8a1Lnh9t1RPnOXud1rZ7wLPvWp7mqTDOYtgorXqlNMhEnI9EqjU33grBx0v3jm0Oc13opkAdmgMQ==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, + "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/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", + "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..69236be --- /dev/null +++ b/packages/graph-viewer/package.json @@ -0,0 +1,56 @@ +{ + "name": "@automem/graph-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "npx vite", + "dev:all": "node ./scripts/dev-all.mjs", + "build": "tsc -b && npx vite build", + "preview": "npx vite preview", + "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", + "@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", + "@react-three/xr": "^6.6.28", + "@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", + "ws": "^8.18.3" + }, + "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/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 new file mode 100644 index 0000000..cf1cd30 --- /dev/null +++ b/packages/graph-viewer/scripts/hand-tracking-server.js @@ -0,0 +1,388 @@ +#!/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'); + +// 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 ============ + +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}`); + phoneClients.add(ws); + broadcastStatus(); + + 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'); + phoneClients.delete(ws); + broadcastStatus(); + }); + + 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); + // 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'); + webClients.delete(ws); + }); +}); + +httpServer.listen(WEB_PORT, () => { + console.log(`\n🌐 Web visualization at http://localhost:${WEB_PORT}`); +}); + +console.log('\n📡 Your Mac IP addresses:'); +for (const ip of getLocalIps()) { + console.log(` ${ip}`); +} +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/App.tsx b/packages/graph-viewer/src/App.tsx new file mode 100644 index 0000000..e88f663 --- /dev/null +++ b/packages/graph-viewer/src/App.tsx @@ -0,0 +1,981 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from 'react' +import { Settings, RotateCcw } from 'lucide-react' + +// Build version - update this when making significant changes +const BUILD_VERSION = '2024-12-23-obsidian-settings-v1' +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' +import { useGraphSnapshot, useProjectedGraph } from './hooks/useGraphData' +import { useAuth } from './hooks/useAuth' +import { GraphCanvas } from './components/GraphCanvas' +import { Inspector } from './components/Inspector' +import { SearchBar } from './components/SearchBar' +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 { 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 { 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' +import { usePathfinding } from './hooks/usePathfinding' +import { useTimeTravel } from './hooks/useTimeTravel' +import { useSoundEffects } from './hooks/useSoundEffects' +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 +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, + pinchPoint: null, + leftPinchRay: null, + rightPinchRay: null, + activePinchRay: null, + zoomDelta: 0, + rotateDelta: 0, + panDelta: { x: 0, y: 0 }, +} + +// Hand icon SVG component +function HandIcon({ className }: { className?: string }) { + return ( + + + + + + + ) +} + +// Bug/Debug icon SVG component +function BugIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ) +} + +// Bolt/Performance icon SVG component +function BoltIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +// 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) + const [hoveredNode, setHoveredNode] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [gestureControlEnabled, setGestureControlEnabled] = useState(false) + const [debugOverlayVisible, setDebugOverlayVisible] = useState(false) + const [performanceMode, setPerformanceMode] = useState(false) + const [settingsPanelOpen, setSettingsPanelOpen] = useState(false) + + // Focus/Spotlight mode is currently disabled + const focusModeEnabled = false + const focusTransition = 0 + + // 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) + + 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) + return params.get('iphone') === 'true' ? 'iphone' : 'mediapipe' + }) + + const [trackingInfo, setTrackingInfo] = useState<{ + source: 'mediapipe' | 'iphone' + iphoneUrl: string + iphoneConnected: boolean + hasLiDAR: boolean + phoneConnected: boolean + bridgeIps: string[] + phonePort: number | null + }>({ + source: trackingSource, + iphoneUrl: 'ws://localhost:8766/ws', + iphoneConnected: false, + hasLiDAR: false, + phoneConnected: false, + bridgeIps: [], + phonePort: null, + }) + + const handleSourceChange = useCallback((source: 'mediapipe' | 'iphone') => { + setTrackingSource(source) + }, []) + + // 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) + + // 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) + // 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) + + // Use either snapshot or projected endpoint based on UMAP setting + const snapshotQuery = useGraphSnapshot({ + limit: filters.maxNodes, + minImportance: filters.minImportance, + types: filters.types.length > 0 ? filters.types : undefined, + enabled: isAuthenticated && !clusterConfig.useUMAP, + }) + + const projectedQuery = useProjectedGraph({ + limit: filters.maxNodes, + minImportance: filters.minImportance, + types: filters.types.length > 0 ? filters.types : undefined, + enabled: isAuthenticated && clusterConfig.useUMAP, + }) + + // Use the active query based on UMAP setting + const { data, isLoading, error, refetch } = clusterConfig.useUMAP + ? projectedQuery + : snapshotQuery + + // Stable data references - use EMPTY constants when data not loaded + const nodes = data?.nodes ?? EMPTY_NODES + const edges = data?.edges ?? EMPTY_EDGES + + const searchLower = searchTerm.toLowerCase() + const matchingNodes = useMemo(() => { + if (!searchTerm) return [] as GraphNode[] + return nodes.filter((n) => + n.content.toLowerCase().includes(searchLower) || + n.tags.some((t) => t.toLowerCase().includes(searchLower)) || + n.type.toLowerCase().includes(searchLower) + ) + }, [nodes, searchTerm]) as GraphNode[] + + // Tag Cloud + const tagCloud = useTagCloud({ + nodes, + typeColors: data?.meta?.type_colors, + }) + + // Sound Effects + const sound = useSoundEffects() + + // Pathfinding + const pathfinding = usePathfinding({ + nodes: nodes as any, + edges, + }) + + // Time Travel + const timeTravel = useTimeTravel({ + 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.playTimeTravel]) + + // Get source and target nodes for pathfinding overlay + const pathSourceNode = useMemo(() => { + 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 || 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(() => { + addBookmark( + { x: cameraStateForBookmarks.x, y: cameraStateForBookmarks.y, z: cameraStateForBookmarks.z }, + cameraStateForBookmarks.zoom, + selectedNode?.id + ) + sound.playBookmark() + }, [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 && nodes.length > 0) { + const node = nodes.find(n => n.id === bookmark.selectedNodeId) + if (node) { + setSelectedNode(node) + } + } + }, [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.playPathFound, sound.playSelect]) + + const handleNodeHover = useCallback((node: GraphNode | null) => { + if (node) { + sound.playHover() + } + setHoveredNode(node) + }, [sound.playHover]) + + // 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.playLasso]) + + 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 (nodes.length === 0 || lassoState.selectedIds.size === 0) return [] + return nodes.filter(n => lassoState.selectedIds.has(n.id)) + }, [nodes, lassoState.selectedIds]) + + useEffect(() => { + if (searchTerm && matchingNodes.length > 0 && !selectedNode) { + setSelectedNode(matchingNodes[0]) + } + }, [matchingNodes, selectedNode, searchTerm]) + + 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.playSearch]) + + const handleFilterChange = useCallback((newFilters: Partial) => { + 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) + }, []) + + const handleToggleLabels = useCallback(() => { + setDisplayConfig(prev => ({ ...prev, showLabels: !prev.showLabels })) + }, []) + + // Keyboard navigation + const handleStartPathfindingFromKeyboard = useCallback(() => { + if (selectedNode) { + pathfinding.startPathSelection(selectedNode.id) + } + }, [selectedNode, pathfinding.startPathSelection]) + + const { shortcuts } = useKeyboardNavigation({ + nodes: nodes as any, + selectedNode, + onNodeSelect: handleNodeSelect, + onReheat: handleReheat, + onResetView: resetViewFn ?? undefined, + onToggleSettings: () => setSettingsPanelOpen(prev => !prev), + onToggleLabels: handleToggleLabels, + 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 + } + + return ( +
+ {/* Top Bar */} +
+
+
+ AM +
+

+ AutoMem +

+
+ + + + + + {/* Version indicator - helps verify deployment */} + + {BUILD_VERSION} + + + {/* Performance Mode Toggle */} + + + {/* Gesture Control Toggle */} + + + {/* Reset View */} + + + {/* Debug Overlay Toggle (only show when gestures enabled) */} + {gestureControlEnabled && ( + + )} + + {/* 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 */} + +
+ + {/* Main Content */} +
+ + {/* Graph Canvas */} + +
+ {isLoading && ( +
+
+
+ Loading memories... +
+
+ )} + + {error && ( +
+
+
Connection Error
+
{(error as Error).message}
+ +
+
+ )} + + setReheatFn(() => fn)} + onResetViewReady={(fn) => setResetViewFn(() => fn)} + focusModeEnabled={focusModeEnabled} + focusTransition={focusTransition} + onCameraStateForBookmarks={setCameraStateForBookmarks} + onNavigateForBookmarks={(fn) => { navigateForBookmarksRef.current = fn }} + pathNodeIds={pathfinding.pathNodeIds} + pathEdgeKeys={pathfinding.pathEdgeKeys} + pathSourceId={pathfinding.sourceId} + pathTargetId={pathfinding.targetId} + isPathSelecting={pathfinding.isSelectingTarget} + timeTravelActive={timeTravel.isActive} + timeTravelVisibleNodes={timeTravel.visibleNodes} + onGetNodesInPolygon={(fn) => { getNodesInPolygonRef.current = fn }} + lassoSelectedIds={lassoState.selectedIds} + tagFilteredNodeIds={tagCloud.filteredNodeIds} + hasTagFilter={tagCloud.hasActiveFilter} + /> + + {/* 2D Hand Overlay (on top of canvas, life-size) */} + + + {/* Gesture Debug Overlay */} + + + {/* Hand Control Overlay (lock/grab metrics) */} + + + {/* Bookmarks Panel */} + + + {/* Pathfinding Overlay */} + + + {/* Time Travel Timeline */} + + + {/* Lasso Selection Overlay */} + + + {/* Selection Actions (bulk operations) */} + +
+ + + {/* Resize Handle */} + + + {/* Inspector Panel */} + + setSelectedNode(null)} + onNavigate={handleNodeSelect} + onStartPathfinding={pathfinding.startPathSelection} + isPathSelecting={pathfinding.isSelectingTarget} + /> + + + + {/* 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} + soundEnabled={sound.settings.enabled} + onSoundEnabledChange={sound.setEnabled} + soundVolume={sound.settings.masterVolume} + onSoundVolumeChange={sound.setMasterVolume} + /> +
+ + {/* Radial Menu (context menu for nodes) */} + {radialMenuState.isOpen && radialMenuState.node && ( + + )} + + {/* Tag Cloud (press 'T' to toggle) */} + setTagCloudVisible(false)} + /> +
+ ) +} diff --git a/packages/graph-viewer/src/api/client.ts b/packages/graph-viewer/src/api/client.ts new file mode 100644 index 0000000..0bf56ca --- /dev/null +++ b/packages/graph-viewer/src/api/client.ts @@ -0,0 +1,232 @@ +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') +} + +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. + */ +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 { + // 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 + } + + const envTarget = getEnvApiTarget() + if (envTarget) { + return envTarget + } + + if (isEmbeddedMode()) { + // In embedded mode, use relative URL (same origin) + return '' + } + return localStorage.getItem('automem_server') || 'https://automem.up.railway.app' +} + +function getTokenFromQuery(): string | null { + const urlParams = new URLSearchParams(window.location.search) + return urlParams.get('token') +} + +function getToken(): string | null { + // Priority: URL query param > URL hash > localStorage + return getTokenFromQuery() || getTokenFromHash() || localStorage.getItem('automem_token') +} + +function getAuthHeaders(): HeadersInit { + return { + 'Content-Type': 'application/json', + } +} + +function addTokenToUrl(url: string): string { + const token = getToken() + if (!token) { + throw new Error('No API token configured') + } + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}api_key=${encodeURIComponent(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 { + // 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() + 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 interface ProjectedParams { + limit?: number + minImportance?: number + types?: string[] + nNeighbors?: number // UMAP n_neighbors (default 15) + minDist?: number // UMAP min_dist (default 0.1) + spread?: number // UMAP spread (default 1.0) +} + +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 = addTokenToUrl(`${getApiBase()}/graph/snapshot?${searchParams}`) + const response = await fetch(url) + return handleResponse(response) +} + +export interface ProjectedSnapshot extends GraphSnapshot { + projection: { + method: string + n_neighbors: number + min_dist: number + spread: number + dimensions: number + umap_time_ms: number + } +} + +export async function fetchProjectedGraph(params: ProjectedParams = {}): 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.nNeighbors) searchParams.set('n_neighbors', String(params.nNeighbors)) + if (params.minDist) searchParams.set('min_dist', String(params.minDist)) + if (params.spread) searchParams.set('spread', String(params.spread)) + + const url = addTokenToUrl(`${getApiBase()}/graph/projected?${searchParams}`) + const response = await fetch(url) + 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 = addTokenToUrl(`${getApiBase()}/graph/neighbors/${memoryId}?${searchParams}`) + const response = await fetch(url) + return handleResponse(response) +} + +export async function fetchGraphStats(): Promise { + const url = addTokenToUrl(`${getApiBase()}/graph/stats`) + const response = await fetch(url) + return handleResponse(response) +} + +export async function updateMemory( + memoryId: string, + updates: { importance?: number; tags?: string[]; content?: string } +): Promise { + const url = addTokenToUrl(`${getApiBase()}/memory/${memoryId}`) + const response = await fetch(url, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify(updates), + }) + await handleResponse(response) +} + +export async function deleteMemory(memoryId: string): Promise { + const url = addTokenToUrl(`${getApiBase()}/memory/${memoryId}`) + const response = await fetch(url, { + 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/BookmarksPanel.tsx b/packages/graph-viewer/src/components/BookmarksPanel.tsx new file mode 100644 index 0000000..cdc74b3 --- /dev/null +++ b/packages/graph-viewer/src/components/BookmarksPanel.tsx @@ -0,0 +1,212 @@ +/** + * BookmarksPanel - Save and restore camera positions + * + * Shows a collapsible list of saved bookmarks with: + * - Click to navigate + * - Delete bookmark + * - Rename bookmark + * - Quick access indicator (1-9) + */ + +import { useState, useCallback } from 'react' +import type { Bookmark } from '../hooks/useBookmarks' + +interface BookmarksPanelProps { + bookmarks: Bookmark[] + onNavigate: (bookmark: Bookmark) => void + onDelete: (id: string) => void + onRename: (id: string, name: string) => void + onSaveBookmark: () => void + visible?: boolean +} + +export function BookmarksPanel({ + bookmarks, + onNavigate, + onDelete, + onRename, + onSaveBookmark, + visible = true, +}: BookmarksPanelProps) { + const [isExpanded, setIsExpanded] = useState(false) + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState('') + + const handleStartEdit = useCallback((bookmark: Bookmark) => { + setEditingId(bookmark.id) + setEditName(bookmark.name) + }, []) + + const handleSaveEdit = useCallback(() => { + if (editingId && editName.trim()) { + onRename(editingId, editName.trim()) + } + setEditingId(null) + setEditName('') + }, [editingId, editName, onRename]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSaveEdit() + } else if (e.key === 'Escape') { + setEditingId(null) + setEditName('') + } + }, [handleSaveEdit]) + + if (!visible) return null + + return ( +
+ {/* Toggle button */} + + + {/* 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/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/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/ExpandedNodeView.tsx b/packages/graph-viewer/src/components/ExpandedNodeView.tsx new file mode 100644 index 0000000..c762868 --- /dev/null +++ b/packages/graph-viewer/src/components/ExpandedNodeView.tsx @@ -0,0 +1,531 @@ +/** + * Expanded Node View + * + * When a user laser-points at a node and activates it (pinch), + * the node expands with a beautiful bloom effect revealing: + * - Full content + * - Metadata (tags, importance, timestamps) + * - Connected nodes as orbiting satellites + * + * Animation sequence: + * 1. Initial pulse/ripple from hit point + * 2. Node scales up with elastic easing + * 3. Content fades in with stagger + * 4. Connections bloom outward + * 5. Orbiting satellites settle into position + */ + +import { useRef, useMemo, useEffect, useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { Text, Billboard, Html } from '@react-three/drei' +import * as THREE from 'three' +import type { GraphEdge, SimulationNode } from '../lib/types' + +// Animation timing +const EXPAND_DURATION = 0.6 // seconds +const CONTENT_DELAY = 0.2 +const CONNECTION_DELAY = 0.35 +const SATELLITE_DELAY = 0.5 + +// Easing functions +const easeOutElastic = (t: number): number => { + const c4 = (2 * Math.PI) / 3 + return t === 0 ? 0 : t === 1 ? 1 : + Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1 +} + +const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3) + +const easeOutBack = (t: number): number => { + const c1 = 1.70158 + const c3 = c1 + 1 + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) +} + +interface ExpandedNodeViewProps { + node: SimulationNode + connectedNodes: SimulationNode[] + edges: GraphEdge[] + hitPoint: { x: number; y: number; z: number } + onClose: () => void + isExpanding: boolean +} + +export function ExpandedNodeView({ + node, + connectedNodes, + edges, + hitPoint, + onClose, + isExpanding, +}: ExpandedNodeViewProps) { + const groupRef = useRef(null) + const rippleRef = useRef(null) + const glowRef = useRef(null) + + // Animation state + const [animationProgress, setAnimationProgress] = useState(0) + const animationStartRef = useRef(0) + const isAnimatingRef = useRef(false) + + // Start animation when expanding + useEffect(() => { + if (isExpanding) { + animationStartRef.current = performance.now() / 1000 + isAnimatingRef.current = true + setAnimationProgress(0) + } + }, [isExpanding]) + + // Animation frame + useFrame((state) => { + if (!isAnimatingRef.current || !groupRef.current) return + + const elapsed = state.clock.elapsedTime - animationStartRef.current + const t = Math.min(1, elapsed / EXPAND_DURATION) + setAnimationProgress(t) + + // Scale animation with elastic easing + const scale = easeOutElastic(t) * 1.5 + 0.5 + groupRef.current.scale.setScalar(scale) + + // Ripple animation + if (rippleRef.current) { + const rippleT = Math.min(1, elapsed / 0.4) + const rippleScale = easeOutCubic(rippleT) * 3 + rippleRef.current.scale.setScalar(rippleScale) + const rippleMat = rippleRef.current.material as THREE.MeshBasicMaterial + rippleMat.opacity = (1 - rippleT) * 0.5 + } + + // Glow pulsing + if (glowRef.current) { + const pulse = 1 + Math.sin(state.clock.elapsedTime * 3) * 0.1 + glowRef.current.scale.setScalar(2.5 * pulse) + } + + // Stop animation when complete + if (t >= 1) { + isAnimatingRef.current = false + } + }) + + // Calculate satellite positions (orbiting connected nodes) + const satellitePositions = useMemo(() => { + const count = connectedNodes.length + if (count === 0) return [] + + const radius = 15 // Orbit radius + return connectedNodes.map((_, i) => { + const angle = (i / count) * Math.PI * 2 + return { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius * 0.6, // Elliptical orbit + z: Math.sin(angle) * radius * 0.3, + } + }) + }, [connectedNodes.length]) + + // Get type color + const typeColor = useMemo(() => { + const typeColors: Record = { + Decision: '#f59e0b', + Pattern: '#10b981', + Insight: '#8b5cf6', + Preference: '#ec4899', + Context: '#3b82f6', + Style: '#06b6d4', + Habit: '#f97316', + } + return typeColors[node.type] || '#6b7280' + }, [node.type]) + + // Content visibility based on animation progress + const contentT = Math.max(0, (animationProgress - CONTENT_DELAY) / (1 - CONTENT_DELAY)) + const connectionT = Math.max(0, (animationProgress - CONNECTION_DELAY) / (1 - CONNECTION_DELAY)) + const satelliteT = Math.max(0, (animationProgress - SATELLITE_DELAY) / (1 - SATELLITE_DELAY)) + + return ( + + {/* Initial ripple from hit point */} + + + + + + {/* Glow sphere */} + + + + + + {/* Main node sphere */} + + + + + + {/* Content card - using HTML for rich text */} + + + 0.8 ? 'auto' : 'none', + }} + > + + + + + + {/* Connection lines to satellites */} + {connectedNodes.map((connNode, i) => { + const satPos = satellitePositions[i] + if (!satPos) return null + + const lineProgress = easeOutCubic(connectionT) + const endX = satPos.x * lineProgress + const endY = satPos.y * lineProgress + const endZ = satPos.z * lineProgress + + return ( + + {/* Connection line */} + + + + + + + + ) + })} + + {/* Satellite nodes */} + {connectedNodes.map((connNode, i) => { + const satPos = satellitePositions[i] + if (!satPos) return null + + const satProgress = easeOutElastic(Math.max(0, satelliteT - i * 0.05)) + const satScale = satProgress * 0.6 + + // Get edge info for this connection + const edge = edges.find( + e => (e.source === node.id && e.target === connNode.id) || + (e.target === node.id && e.source === connNode.id) + ) + + return ( + + {/* Satellite sphere */} + + + + + + {/* Satellite label */} + + + {connNode.content.slice(0, 25)}... + + {edge && ( + + {edge.type} + + )} + + + ) + })} + + {/* Floating particles for ambiance */} + + + ) +} + +/** + * Content Card - HTML overlay with node details + */ +interface ContentCardProps { + node: SimulationNode + onClose: () => void + typeColor: string +} + +function ContentCard({ node, onClose, typeColor }: ContentCardProps) { + return ( +
+ {/* Header */} +
+ + {node.type} + + +
+ + {/* 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/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/GestureDebugOverlay.tsx b/packages/graph-viewer/src/components/GestureDebugOverlay.tsx new file mode 100644 index 0000000..cadc806 --- /dev/null +++ b/packages/graph-viewer/src/components/GestureDebugOverlay.tsx @@ -0,0 +1,282 @@ +/** + * 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) + const fmtMeters = (n: number) => `${fmt(n, 2)}m` + + // 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] + 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) as number, 2)} + {worldZ > 0 ? ` | ${fmtMeters(worldZ)}` : ''}) + +
+ ) + })} +
+
+ )} + + {/* Right Hand Landmarks */} + {rightHand && ( +
+
Right Hand (21 landmarks)
+
+ {[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) as number, 2)} + {worldZ > 0 ? ` | ${fmtMeters(worldZ)}` : ''}) + +
+ ) + })} +
+
+ )} + + {/* 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 new file mode 100644 index 0000000..bc49856 --- /dev/null +++ b/packages/graph-viewer/src/components/GraphCanvas.tsx @@ -0,0 +1,1760 @@ +/** + * GraphCanvas - High-performance 3D memory visualization + * + * Performance optimizations: + * - Instanced mesh rendering for nodes (1 draw call for all nodes) + * - Batched LineSegments for edges (1 draw call for all edges) + * - Reduced geometry complexity (12x12 segments vs 32x32) + * - LOD for labels (only show labels for nearby/selected nodes) + * - Optional post-processing (performance mode toggle) + * - Single useFrame callback for all animations + * + * Interaction model (simplified): + * - Mouse: Click nodes to select, OrbitControls for navigation + * - Hand gestures: Two-hand pinch to pan/zoom/rotate; one-hand fist grab to pan + */ + +import { useRef, useMemo, useState, useCallback, useEffect } from 'react' +import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber' +import { OrbitControls, Text, Billboard } from '@react-three/drei' +import { XR, createXRStore } from '@react-three/xr' +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 { useHandLockAndGrab } from '../hooks/useHandLockAndGrab' +import type { + GraphNode, + GraphEdge, + SimulationNode, + ForceConfig, + DisplayConfig, + ClusterConfig, + RelationshipVisibility, +} 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, PinchPreSelectHighlight } from './SelectionHighlight' +import { getEdgeStyle } from '../lib/edgeStyles' +import { EdgeParticles } from './EdgeParticles' +import { MiniMap } from './MiniMap' +import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib' + +// 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) + const url = params.get('iphone_url') + if (url) { + setIphoneUrl(url) + } + }, []) + + return iphoneUrl +} + +// 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 +const MAX_VISIBLE_LABELS = 10 // Maximum labels to show at once (for LOD) + +// XR store for WebXR (Quest VR support) +const xrStore = createXRStore() + +interface GraphCanvasProps { + nodes: GraphNode[] + edges: GraphEdge[] + selectedNode: GraphNode | null + hoveredNode: GraphNode | null + 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 + onTrackingInfoChange?: (info: { + source: 'mediapipe' | 'iphone' + iphoneUrl: string + iphoneConnected: boolean + hasLiDAR: boolean + phoneConnected: boolean + bridgeIps: string[] + phonePort: number | null + }) => void + performanceMode?: boolean + forceConfig?: ForceConfig + displayConfig?: DisplayConfig + clusterConfig?: ClusterConfig + relationshipVisibility?: RelationshipVisibility + 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({ + nodes, + edges, + selectedNode, + hoveredNode, + searchTerm, + onNodeSelect, + onNodeHover, + onNodeContextMenu, + gestureControlEnabled = false, + trackingSource: source = 'mediapipe', + onGestureStateChange, + onTrackingInfoChange, + performanceMode = false, + forceConfig = DEFAULT_FORCE_CONFIG, + displayConfig = DEFAULT_DISPLAY_CONFIG, + clusterConfig = DEFAULT_CLUSTER_CONFIG, + relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, + 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([]) + // Bimanual grab state for visual feedback + const [bimanualActive, setBimanualActive] = useState(false) + const [isVrSupported, setIsVrSupported] = useState(false) + 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() + + useEffect(() => { + let active = true + + const isSecureContext = typeof window !== 'undefined' ? window.isSecureContext : false + if (typeof navigator === 'undefined' || !isSecureContext || !navigator.xr?.isSessionSupported) { + setIsVrSupported(false) + return () => { + active = false + } + } + + navigator.xr.isSessionSupported('immersive-vr') + .then((supported) => { + if (active) setIsVrSupported(supported) + }) + .catch(() => { + if (active) setIsVrSupported(false) + }) + + return () => { + active = false + } + }, []) + + // 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, + hasLiDAR, + phoneConnected, + bridgeIps, + phonePort, + } = 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 + + useEffect(() => { + onTrackingInfoChange?.({ + source, + iphoneUrl, + iphoneConnected, + hasLiDAR, + phoneConnected, + bridgeIps, + phonePort, + }) + }, [onTrackingInfoChange, source, iphoneUrl, iphoneConnected, hasLiDAR, phoneConnected, bridgeIps, phonePort]) + + return ( +
+ + + + + + + {/* VR Button - Enter Quest VR */} + {isVrSupported && ( + + )} + + {/* MiniMap Navigator */} + 0} + size={140} + /> +
+ ) +} + +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 + // Bimanual world-manipulation feedback + onBimanualGrabChange?: (active: boolean) => void +} + +function Scene({ + nodes, + edges, + selectedNode, + hoveredNode, + searchTerm, + onNodeSelect, + onNodeHover, + onNodeContextMenu, + gestureState, + gestureControlEnabled, + performanceMode, + forceConfig = DEFAULT_FORCE_CONFIG, + displayConfig = DEFAULT_DISPLAY_CONFIG, + clusterConfig = DEFAULT_CLUSTER_CONFIG, + relationshipVisibility = DEFAULT_RELATIONSHIP_VISIBILITY, + typeColors = {}, + onReheatReady, + onResetViewReady, + focusModeEnabled, + focusTransition, + onCameraStateChange, + onLayoutNodesChange, + onNavigateToReady, + pathNodeIds, + pathEdgeKeys, + pathSourceId, + pathTargetId, + isPathSelecting: _isPathSelecting, + timeTravelActive = false, + timeTravelVisibleNodes, + onGetNodesInPolygon, + lassoSelectedIds, + tagFilteredNodeIds, + hasTagFilter = false, + onBimanualGrabChange, +}: SceneProps) { + const { camera } = useThree() + const { nodes: layoutNodes, isSimulating, reheat } = useForceLayout({ + nodes, + edges, + forceConfig, + useServerPositions: clusterConfig?.useUMAP, + seedMode: clusterConfig.mode, + }) + + // DEBUG: Log node counts + // 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, + edges, + mode: clusterConfig.mode, + typeColors, + }) + + // Expose reheat function to parent + useEffect(() => { + if (onReheatReady) { + 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]) + + // 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) + + // 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, + }) + } + }) + + // Hand controls: two-hand pinch world manipulation + single-hand lock/grab/pinch + const { + lock: handLock, + deltas: grabDeltas, + clearRequested, + bimanualPinch, + leftMetrics, + rightMetrics, + } = useHandLockAndGrab(gestureState, gestureControlEnabled) + + // Clear selection when user holds open palm for ~0.5 seconds + const clearWasRequestedRef = useRef(false) + useEffect(() => { + if (clearRequested && !clearWasRequestedRef.current && selectedNode) { + onNodeSelect(null) + } + clearWasRequestedRef.current = clearRequested + }, [clearRequested, selectedNode, onNodeSelect]) + + // Notify parent of bimanual grab state for visual feedback (border glow) + useEffect(() => { + onBimanualGrabChange?.(bimanualPinch) + }, [bimanualPinch, onBimanualGrabChange]) + + // 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]) + + // 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 hovered node from layout (for hand-tracking pre-select highlight) + const hoveredLayoutNode = useMemo(() => { + if (!hoveredNode) return null + return layoutNodes.find(n => n.id === hoveredNode.id) ?? null + }, [hoveredNode, layoutNodes]) + + // Track pinch strength for visual feedback (updated in useFrame) + const [pinchStrength, setPinchStrength] = useState(0) + const pinchStrengthRef = useRef(0) + + // Stop auto-rotate on user interaction + const handleInteractionStart = useCallback(() => { + setAutoRotate(false) + }, []) + + // Track world position at grab start for displacement-based movement + const grabStartPosRef = useRef({ x: 0, y: 0, z: 0 }) + const grabPrevTargetRef = useRef(new THREE.Vector3()) + const grabVelocityRef = useRef(new THREE.Vector3()) + const wasGrabbingRef = useRef(false) + const inertiaActiveRef = useRef(false) + + // Bimanual navigation: two-hand pinch to pan/zoom/rotate the cloud + const wasBimanualRef = useRef(false) + const bimanualSmoothedRef = useRef<{ + distance: number + angle: number + center: { x: number; y: number } + } | null>(null) + + // Direct pinch selection ("pick the berry") + // Position pinchPoint over a node on screen, pinch to select + const PINCH_SELECT_RADIUS = 50 // pixels - fixed radius for selection + const handHoverIdRef = useRef(null) + const pinchWasActiveRef = useRef(false) // for edge detection + const lastClickMsRef = useRef(0) + + // Temp objects for grab calculations + 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 isLocked = handLock.mode === 'locked' + const isGrabbing = isLocked && handLock.grabbed + + // --- Bimanual pinch: two-point transform (pan/zoom/rotate) --- + if (bimanualPinch && leftMetrics && rightMetrics) { + 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 + + const center = { x: (left.x + right.x) / 2, y: (left.y + right.y) / 2 } + const dx = right.x - left.x + const dyUp = -(right.y - left.y) // flip Y so "up" is positive for angles + const distance = Math.sqrt(dx * dx + dyUp * dyUp) + + const canonicalSegmentAngle = (angle: number) => { + // Treat the segment as undirected: wrap to [-pi/2, pi/2) so swapping endpoints doesn't jump by pi. + let a = angle + while (a >= Math.PI / 2) a -= Math.PI + while (a < -Math.PI / 2) a += Math.PI + return a + } + + const normalizeDeltaPi = (delta: number) => { + // Normalize to [-pi/2, pi/2] to match canonical segment angle range. + let d = delta + while (d > Math.PI / 2) d -= Math.PI + while (d < -Math.PI / 2) d += Math.PI + return d + } + + const angle = canonicalSegmentAngle(Math.atan2(dyUp, dx)) + + 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, + } + + // First frame: initialize smoothing state but don't apply a jump. + if (!wasBimanualRef.current) { + bimanualSmoothedRef.current = nextSmooth + wasBimanualRef.current = true + wasGrabbingRef.current = false + return + } + + bimanualSmoothedRef.current = nextSmooth + + const panDx = nextSmooth.center.x - prevSmooth.center.x + const panDy = nextSmooth.center.y - prevSmooth.center.y + const rotationDelta = normalizeDeltaPi(nextSmooth.angle - prevSmooth.angle) + + const distRatio = Math.max(1e-4, nextSmooth.distance) / Math.max(1e-4, prevSmooth.distance) + const zoomDelta = Math.log(distRatio) + + 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 + bimanualSmoothedRef.current = null + } + + // --- Grab: follow target with damping + inertial coast on release --- + if (isGrabbing) { + // On first frame of grab, capture current world position + if (grabDeltas.grabStart || !wasGrabbingRef.current) { + grabStartPosRef.current = { + x: group.position.x, + y: group.position.y, + z: group.position.z, + } + grabPrevTargetRef.current.set(group.position.x, group.position.y, group.position.z) + grabVelocityRef.current.set(0, 0, 0) + inertiaActiveRef.current = false + } + + // Target position relative to grab start + const startPos = grabStartPosRef.current + 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 + + // --- Direct pinch selection ("pick the berry") --- + // Only active when locked and not grabbing + const pinchActive = isLocked && !isGrabbing + + // Update pinch strength for visual feedback + const currentPinchStrength = isLocked ? handLock.metrics.pinch : 0 + if (Math.abs(currentPinchStrength - pinchStrengthRef.current) > 0.02) { + pinchStrengthRef.current = currentPinchStrength + setPinchStrength(currentPinchStrength) + } + + if (!pinchActive) { + // Clear hover when not in selection mode + if (handHoverIdRef.current !== null) { + onNodeHover(null) + handHoverIdRef.current = null + } + pinchWasActiveRef.current = false + // Reset pinch strength when not active + if (pinchStrengthRef.current > 0.01) { + pinchStrengthRef.current = 0 + setPinchStrength(0) + } + return + } + + // Use the locked hand's pinch point when available, otherwise prefer right then left. + const pinchPoint = + handLock.mode === 'locked' + ? handLock.metrics.pinchPoint + : rightMetrics?.pinchPoint ?? leftMetrics?.pinchPoint ?? null + if (!pinchPoint) { + if (handHoverIdRef.current !== null) { + onNodeHover(null) + handHoverIdRef.current = null + } + return + } + + // Get canvas size for screen-space calculations + const canvas = document.querySelector('canvas') + if (!canvas) return + const rect = canvas.getBoundingClientRect() + + // Convert pinchPoint (0-1 normalized) to screen pixels + const pinchScreenX = pinchPoint.x * rect.width + const pinchScreenY = pinchPoint.y * rect.height + + // Find nearest node to pinchPoint in screen space + let nearestNode: SimulationNode | null = null + let nearestDist = Infinity + + for (const n of layoutNodes) { + // Get node world position (accounting for group transform) + const worldPos = new THREE.Vector3(n.x ?? 0, n.y ?? 0, n.z ?? 0) + group.localToWorld(worldPos) + + // Project to screen coordinates + const projected = worldPos.project(camera) + const screenX = ((projected.x + 1) / 2) * rect.width + const screenY = ((-projected.y + 1) / 2) * rect.height + + // Calculate distance to pinch point + const dx = screenX - pinchScreenX + const dy = screenY - pinchScreenY + const dist = Math.sqrt(dx * dx + dy * dy) + + // Check if within selection radius and closer than current best + if (dist < PINCH_SELECT_RADIUS && dist < nearestDist) { + nearestDist = dist + nearestNode = n + } + } + + // Update hover state based on nearest node + if (nearestNode) { + if (handHoverIdRef.current !== nearestNode.id) { + onNodeHover(nearestNode) + handHoverIdRef.current = nearestNode.id + } + } else if (handHoverIdRef.current !== null) { + onNodeHover(null) + handHoverIdRef.current = null + } + + // Get pinch activation state (with hysteresis from useHandLockAndGrab) + const pinchActivated = handLock.mode === 'locked' && handLock.pinchActivated + + // Pinch selection (edge triggered: select on rising edge of pinchActivated) + if (pinchActivated && !pinchWasActiveRef.current && nearestNode) { + const nowMs = performance.now() + // Debounce to prevent rapid double-selects + if (nowMs - lastClickMsRef.current > 250) { + lastClickMsRef.current = nowMs + onNodeSelect(nearestNode) + } + } + pinchWasActiveRef.current = pinchActivated + }) + + return ( + <> + {/* Ambient lighting */} + + + + + {/* Camera controls */} + + + {/* Graph content */} + + {/* Batched edges - single draw call for all edges */} + {/* Cluster boundaries (rendered behind edges) */} + + + {/* Batched edges - single draw call for all edges */} + + + {/* Ambient edge particles - flowing along edges */} + + + {/* Instanced nodes - single draw call for all nodes */} + + + {/* Selection highlight - glowing ring around selected node */} + {selectedLayoutNode && ( + + )} + + {/* Pinch pre-select highlight - tightening ring for "pick the berry" selection */} + {gestureControlEnabled && ( + + )} + + {/* Connected paths highlight - particles flowing to connected nodes */} + {selectedNode && connectedNodes.length > 0 && ( + + )} + + {/* LOD Labels - only for selected/hovered/nearby nodes */} + {displayConfig.showLabels && ( + + )} + + + + {/* Post-processing effects - conditional based on performance mode */} + {!performanceMode && ( + + + + + )} + + ) +} + +/** + * Batched edge rendering using LineSegments + * 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 + focusStates: Map + pathEdgeKeys?: Set + timeTravelActive?: boolean + timeTravelVisibleNodes?: Set + tagFilteredNodeIds?: Set + hasTagFilter?: boolean +} + +function BatchedEdges({ + edges, + nodeById, + selectedNode, + connectedIds, + relationshipVisibility, + linkThickness, + linkOpacity, + focusStates, + pathEdgeKeys, + timeTravelActive = false, + timeTravelVisibleNodes, + tagFilteredNodeIds, + hasTagFilter = false, +}: BatchedEdgesProps) { + const lineRef = useRef(null) + + // 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 + + // 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 && + !connectedIds.has(edge.source) && + !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) + // Target vertex + positions.push(targetNode.x ?? 0, targetNode.y ?? 0, targetNode.z ?? 0) + + // Get style for this edge type + const style = getEdgeStyle(edge.type) + + // 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 * 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) + } + + // Apply alpha to color (approximate, since LineBasicMaterial doesn't support per-vertex alpha) + const r = color.r * alpha + const g = color.g * alpha + const b = color.b * alpha + + // Color for source and target vertices + colors.push(r, g, b, r, g, b) + }) + + return { + positions: new Float32Array(positions), + colors: new Float32Array(colors), + visibleCount, + } + }, [edges, nodeById, selectedNode, connectedIds, relationshipVisibility, linkOpacity, focusStates, pathEdgeKeys, timeTravelActive, timeTravelVisibleNodes, tagFilteredNodeIds, hasTagFilter]) + + // Update geometry when positions/colors change + useEffect(() => { + if (!lineRef.current) return + + const geometry = lineRef.current.geometry + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)) + geometry.attributes.position.needsUpdate = true + geometry.attributes.color.needsUpdate = true + geometry.computeBoundingSphere() + }, [positions, colors]) + + if (visibleCount === 0) return null + + return ( + + + + + ) +} + +/** + * Instanced node rendering + * All nodes rendered in a single draw call using InstancedMesh + */ +interface InstancedNodesProps { + nodes: SimulationNode[] + selectedNode: GraphNode | null + hoveredNode: GraphNode | null + searchTerm: string + matchingIds: Set + 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({ + nodes, + selectedNode, + hoveredNode, + searchTerm, + matchingIds, + 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, 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) + 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( + () => + new THREE.MeshStandardMaterial({ + roughness: 0.3, + metalness: 0.1, + transparent: true, + }), + [] + ) + + // Animation state - recreate when node count changes + const nodeCount = nodes.length + const scalesRef = useRef(new Float32Array(0)) + const targetScalesRef = useRef(new Float32Array(0)) + // Deep dive: z-offset for selected node (pulls toward camera) + const zOffsetsRef = useRef(new Float32Array(0)) + const targetZOffsetsRef = useRef(new Float32Array(0)) + + // Resize animation arrays when node count changes + useEffect(() => { + if (scalesRef.current.length !== nodeCount) { + scalesRef.current = new Float32Array(nodeCount) + targetScalesRef.current = new Float32Array(nodeCount) + zOffsetsRef.current = new Float32Array(nodeCount) + targetZOffsetsRef.current = new Float32Array(nodeCount) + // Initialize scales to 1 and z-offsets to 0 + for (let i = 0; i < nodeCount; i++) { + scalesRef.current[i] = 1 + targetScalesRef.current[i] = 1 + zOffsetsRef.current[i] = 0 + targetZOffsetsRef.current[i] = 0 + } + } + }, [nodeCount]) + + // Temp objects for matrix calculations (reused to avoid GC) + const tempMatrix = useMemo(() => new THREE.Matrix4(), []) + const tempColor = useMemo(() => new THREE.Color(), []) + const tempPosition = useMemo(() => new THREE.Vector3(), []) + const tempQuaternion = useMemo(() => new THREE.Quaternion(), []) + const tempScale = useMemo(() => new THREE.Vector3(), []) + + // Update instance matrices and colors each frame + useFrame((_, delta) => { + if (!meshRef.current) return + + const mesh = meshRef.current + + nodes.forEach((node, i) => { + 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)) || + // Dim non-path nodes when path is active + (hasActivePath && !isInPath) || + // Dim nodes not matching tag filter + (hasTagFilterRef.current && !isMatchingTagFilter) + ) + + // Node color (cluster coloring is done at scene level, not instanced nodes) + const baseNodeColor = node.color + + // 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 + const currentScale = scalesRef.current[i] || 1 + const newScale = THREE.MathUtils.lerp(currentScale, targetScale, delta * 10) + scalesRef.current[i] = newScale + + // Deep dive z-offset: selected node pops toward camera, connected nodes follow slightly + // This creates a "focus" effect where the selected node comes forward + const DEEP_DIVE_DISTANCE = 25 // How far forward selected node moves + const CONNECTED_DIVE_DISTANCE = 10 // How far connected nodes follow + let targetZOffset = 0 + if (isSelected) { + targetZOffset = DEEP_DIVE_DISTANCE + } else if (selectedNode && connectedIds.has(node.id)) { + targetZOffset = CONNECTED_DIVE_DISTANCE + } + targetZOffsetsRef.current[i] = targetZOffset + + // Smooth z-offset animation (slightly slower for dramatic effect) + const currentZOffset = zOffsetsRef.current[i] || 0 + const newZOffset = THREE.MathUtils.lerp(currentZOffset, targetZOffset, delta * 6) + zOffsetsRef.current[i] = newZOffset + + // 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 and deep-dive z-offset) + // z-offset moves node toward camera (positive z in screen space) + tempPosition.set(node.x ?? 0, node.y ?? 0, (node.z ?? 0) + newZOffset) + tempScale.setScalar(node.radius * finalScale * nodeSizeScale) + tempMatrix.compose(tempPosition, tempQuaternion, tempScale) + mesh.setMatrixAt(i, tempMatrix) + + // 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(baseNodeColor) + // Add blue tint by lerping toward blue + const blueColor = new THREE.Color('#3b82f6') + tempColor.lerp(blueColor, 0.35) + } else { + // Normal node color + tempColor.set(baseNodeColor) + } + + if (isDimmed && !isInPath && !isLassoSelected) { + tempColor.multiplyScalar(0.35) // was 0.15 - too aggressive + finalScale *= 0.4 + } 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) + } + + if (searchTerm && !isSearchMatch) { + finalScale *= 0.02 + } + + mesh.setColorAt(i, tempColor) + }) + + mesh.instanceMatrix.needsUpdate = true + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true + }) + + // Handle click/hover via raycasting + const handlePointerMove = useCallback( + (_event: ThreeEvent) => { + if (!meshRef.current) return + + 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) { + onNodeHover(node) + document.body.style.cursor = 'pointer' + return + } + } + } + + onNodeHover(null) + document.body.style.cursor = 'default' + }, + [camera, pointer, raycaster, nodeIndexMap, onNodeHover] + ) + + 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] + ) + + // R3F onClick handler - uses R3F's event system which works with OrbitControls + const handleClick = useCallback( + (event: ThreeEvent) => { + if (!meshRef.current) return + + 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() + // Toggle selection + onNodeSelect(selectedNode?.id === node.id ? null : node) + } + } + } + }, + [camera, pointer, raycaster, nodeIndexMap, onNodeSelect, selectedNode] + ) + + return ( + + ) +} + +/** + * LOD Labels - Only render labels for nearby/selected/hovered nodes + * Uses distance-based culling and limits max visible labels + */ +interface LODLabelsProps { + nodes: SimulationNode[] + selectedNode: GraphNode | null + hoveredNode: GraphNode | null + searchTerm: string + matchingIds: Set + labelFadeDistance?: number +} + +function LODLabels({ + nodes, + selectedNode, + hoveredNode, + searchTerm, + matchingIds, + labelFadeDistance = LABEL_DISTANCE_THRESHOLD, +}: LODLabelsProps) { + const { camera } = useThree() + const [visibleNodes, setVisibleNodes] = useState([]) + + // Update visible labels based on camera distance + useFrame(() => { + const cameraPos = camera.position + + // Always show selected and hovered nodes + const priorityNodes: SimulationNode[] = [] + const nearbyNodes: { node: SimulationNode; distance: number }[] = [] + + nodes.forEach((node) => { + const isSelected = selectedNode?.id === node.id + const isHovered = hoveredNode?.id === node.id + const isSearchMatch = !!searchTerm && matchingIds.has(node.id) + + if (isSelected || isHovered) { + priorityNodes.push(node) + return + } + + // Calculate distance to camera + const dx = (node.x ?? 0) - cameraPos.x + const dy = (node.y ?? 0) - cameraPos.y + const dz = (node.z ?? 0) - cameraPos.z + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) + + // Include search matches and nearby nodes + if (distance < labelFadeDistance || isSearchMatch) { + nearbyNodes.push({ node, distance }) + } + }) + + // Sort by distance and limit + nearbyNodes.sort((a, b) => a.distance - b.distance) + const nearbyToShow = nearbyNodes + .slice(0, MAX_VISIBLE_LABELS - priorityNodes.length) + .map((n) => n.node) + + setVisibleNodes([...priorityNodes, ...nearbyToShow]) + }) + + return ( + <> + {visibleNodes.map((node) => ( + + ))} + + ) +} + +interface NodeLabelProps { + node: SimulationNode + isSelected: boolean + isHovered: boolean +} + +function NodeLabel({ node, isSelected, isHovered }: NodeLabelProps) { + // 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 ( + + + {label} + + {(isSelected || isHovered) && ( + + {node.type} + + )} + + ) +} diff --git a/packages/graph-viewer/src/components/Hand2DOverlay.tsx b/packages/graph-viewer/src/components/Hand2DOverlay.tsx new file mode 100644 index 0000000..43c1f28 --- /dev/null +++ b/packages/graph-viewer/src/components/Hand2DOverlay.tsx @@ -0,0 +1,539 @@ +/** + * Hand 2D Overlay + * + * 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 } from '../hooks/useHandGestures' +import type { HandLockState } from '../hooks/useHandLockAndGrab' + +// Fingertip indices +const FINGERTIPS = [4, 8, 12, 16, 20] + +// Knuckle indices (base of fingers) +const KNUCKLES = [5, 9, 13, 17] + +// Smoothing configuration +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 + +interface SmoothedHand { + landmarks: { x: number; y: number; z: number }[] + lastSeen: number + isGhost: boolean + opacity: number +} + +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) + 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 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 + (zTarget - prevLm.z) * SMOOTHING_FACTOR, + } + } + return { x: lm.x, y: lm.y, z: zTarget } + }) + 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 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 + (zTarget - prevLm.z) * SMOOTHING_FACTOR, + } + } + return { x: lm.x, y: lm.y, z: zTarget } + }) + 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]) + + if (!enabled || !gestureState.isTracking) return null + + // Visibility gating: + // Keep both hands visually consistent; only boost opacity when the user is actively interacting. + const baseOpacity = lock?.mode === 'locked' ? 0.85 : 0.18 + + return ( +
+ + {/* Define filters and gradients */} + + {/* Ghost glow filter */} + + + + + + + + + + {/* Grip glow filter */} + + + + + + + + + + {/* Gradient for 3D depth effect - cyan */} + + + + + + {/* Gradient for 3D depth effect - magenta */} + + + + + + + {/* Left hand - cyan ghost */} + {leftSmoothed && ( + + + + )} + + {/* Right hand - magenta ghost */} + {rightSmoothed && ( + + + + )} + +
+ ) +} + +interface GhostHandProps { + landmarks: { x: number; y: number; z: number }[] + color: string + gradientId: string + isGhost?: boolean + opacityMultiplier?: number +} + +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, + color: _color, + gradientId: _gradientId, + isGhost = false, + opacityMultiplier = 1, +}: GhostHandProps) { + const wristZ = landmarks[0].z || 0 + + // 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 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 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 (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 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 + const effectiveOpacityMultiplier = opacityMultiplier * depthOpacity + + const clampedScale = Math.max(0.3, Math.min(2.0, scaleFactor)) + + // "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: lm.x * 100, + y: lm.y * 100, + }) + + // 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 + const fingerWidth = 1.8 * clampedScale + + // Unique ID for this hand's gradients/filters + const handId = Math.round(points[0].x * 10) + + // Helper: get perpendicular offset for finger width + 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 + return { x: -dy / len * width, y: dx / len * width } + } + + // Create filled finger shape (capsule/sausage shape) + const createFingerShape = (indices: number[], width: number) => { + const pts = indices.map(i => points[i]) + if (pts.length < 2) return '' + + // Build outline going down one side and back up the other + const leftSide: string[] = [] + const rightSide: string[] = [] + + for (let i = 0; i < pts.length - 1; i++) { + const perp = getPerpendicular(pts[i], pts[i + 1], width) + leftSide.push(`${pts[i].x + perp.x},${pts[i].y + perp.y}`) + rightSide.unshift(`${pts[i].x - perp.x},${pts[i].y - perp.y}`) + } + + // Add rounded tip + const lastPt = pts[pts.length - 1] + const prevPt = pts[pts.length - 2] + const tipPerp = getPerpendicular(prevPt, lastPt, width) + + // Rounded end cap using arc + 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`; + } + + // Create palm shape connecting all finger bases + const createPalmShape = () => { + // Palm outline: wrist -> thumb base -> around finger bases -> back to wrist + const wrist = points[0] + const thumbBase = points[1] + const indexBase = points[5] + const middleBase = points[9] + const ringBase = points[13] + const pinkyBase = points[17] + + // Offset points outward for palm width + const palmWidth = fingerWidth * 1.2 + + return ` + M ${wrist.x} ${wrist.y + palmWidth} + Q ${thumbBase.x - palmWidth} ${thumbBase.y} ${thumbBase.x} ${thumbBase.y - palmWidth * 0.5} + L ${indexBase.x - palmWidth * 0.3} ${indexBase.y - palmWidth * 0.5} + Q ${(indexBase.x + middleBase.x) / 2} ${Math.min(indexBase.y, middleBase.y) - palmWidth * 0.8} + ${middleBase.x} ${middleBase.y - palmWidth * 0.5} + Q ${(middleBase.x + ringBase.x) / 2} ${Math.min(middleBase.y, ringBase.y) - palmWidth * 0.6} + ${ringBase.x} ${ringBase.y - palmWidth * 0.5} + Q ${(ringBase.x + pinkyBase.x) / 2} ${Math.min(ringBase.y, pinkyBase.y) - palmWidth * 0.5} + ${pinkyBase.x + palmWidth * 0.3} ${pinkyBase.y - palmWidth * 0.3} + L ${pinkyBase.x + palmWidth * 0.5} ${pinkyBase.y + palmWidth * 0.5} + Q ${wrist.x + palmWidth * 1.5} ${(wrist.y + pinkyBase.y) / 2} + ${wrist.x} ${wrist.y + palmWidth} + Z + `; + } + + // Finger definitions: [landmark indices] + const fingers = [ + { indices: [1, 2, 3, 4], width: fingerWidth * 0.9 }, // Thumb (slightly thinner) + { indices: [5, 6, 7, 8], width: fingerWidth }, // Index + { indices: [9, 10, 11, 12], width: fingerWidth * 1.05 }, // Middle (slightly thicker) + { indices: [13, 14, 15, 16], width: fingerWidth * 0.95 }, // Ring + { indices: [17, 18, 19, 20], width: fingerWidth * 0.85 }, // Pinky (thinnest) + ] + + return ( + + {/* Definitions */} + + {/* Main hand gradient - white to soft lavender */} + + + + + + + + {/* Ambient occlusion gradient for creases */} + + + + + + + {/* Rim light gradient */} + + + + + + + {/* Soft blur for glow effect */} + + + + + + + + + {/* Drop shadow */} + + + + + + {/* Shadow layer */} + + {/* Palm base shape */} + + + {/* Fingers - rendered back to front for proper overlapping */} + {[...fingers].reverse().map((finger, idx) => ( + + ))} + + + {/* Ambient occlusion in creases (between fingers) */} + {[5, 9, 13].map((baseIdx, idx) => { + const p1 = points[baseIdx] + const p2 = points[baseIdx + 4] + return ( + + ) + })} + + {/* Knuckle definition shadows */} + {KNUCKLES.map((idx) => { + const p = points[idx] + return ( + + ) + })} + + {/* Highlight layer - rim lighting effect */} + + {/* Palm highlight */} + + + {/* Finger highlights */} + {fingers.map((finger, idx) => ( + + ))} + + {/* Fingertip highlights - small specular dots */} + {FINGERTIPS.map((idx) => { + const p = points[idx] + return ( + + ) + })} + + + {/* Wrist cuff */} + + + ) +} + +export default Hand2DOverlay diff --git a/packages/graph-viewer/src/components/HandControlOverlay.tsx b/packages/graph-viewer/src/components/HandControlOverlay.tsx new file mode 100644 index 0000000..1a806c7 --- /dev/null +++ b/packages/graph-viewer/src/components/HandControlOverlay.tsx @@ -0,0 +1,170 @@ +import type { HandLockState } from '../hooks/useHandLockAndGrab' + +interface HandControlOverlayProps { + enabled: boolean + lock: HandLockState + source: 'mediapipe' | 'iphone' + onSourceChange?: (source: 'mediapipe' | 'iphone') => void + onResetView?: () => void + iphoneConnected?: boolean + hasLiDAR?: boolean + iphoneUrl?: string + phoneConnected?: boolean + bridgeIps?: string[] + phonePort?: number | null +} + +export function HandControlOverlay({ + enabled, + lock, + source, + onSourceChange, + onResetView, + iphoneConnected = false, + hasLiDAR = false, + iphoneUrl, + phoneConnected = false, + bridgeIps = [], + phonePort = null, +}: HandControlOverlayProps) { + if (!enabled) return null + + const badge = + lock.mode === 'locked' + ? lock.grabbed + ? { 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' } + + const m = lock.mode === 'idle' ? lock.metrics : lock.metrics + + return ( +
+
+
+ Hand Control +
+ {onResetView && ( + + )} + {badge.text} +
+
+ + {/* Source Toggle */} +
+ Source +
+ + +
+
+ + {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 && ( +
+
+ spread + {m.spread.toFixed(2)} +
+
+ palm + {m.palmFacing.toFixed(2)} +
+
+ point + {m.point.toFixed(2)} +
+
+ pinch + {m.pinch.toFixed(2)} +
+
+ grab + {m.grab.toFixed(2)} +
+
+ depth + {m.depth.toFixed(3)} +
+
+ )} + +
+
+ Acquire: raise open palm + spread fingers +
+
+ Navigate: pinch with both hands to pan/zoom/rotate the world +
+
+ Select: point (index out) + pinch thumb/index to click +
+
+
+
+ ) +} + +export default HandControlOverlay diff --git a/packages/graph-viewer/src/components/Inspector.tsx b/packages/graph-viewer/src/components/Inspector.tsx new file mode 100644 index 0000000..ce9985d --- /dev/null +++ b/packages/graph-viewer/src/components/Inspector.tsx @@ -0,0 +1,321 @@ +import { useState } from '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' +import type { GraphNode } from '../lib/types' + +interface InspectorProps { + node: GraphNode | null + onClose: () => void + onNavigate: (node: GraphNode) => void + onStartPathfinding?: (nodeId: string) => void + isPathSelecting?: boolean +} + +export function Inspector({ node, onClose, onNavigate, onStartPathfinding, isPathSelecting }: 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 */} +
+ {/* Find Path Button */} + {onStartPathfinding && ( + + )} + + +
+
+ ) +} diff --git a/packages/graph-viewer/src/components/LassoOverlay.tsx b/packages/graph-viewer/src/components/LassoOverlay.tsx new file mode 100644 index 0000000..ac6e2e8 --- /dev/null +++ b/packages/graph-viewer/src/components/LassoOverlay.tsx @@ -0,0 +1,171 @@ +/** + * LassoOverlay - SVG overlay for drawing lasso selection path + * + * Features: + * - Draws the lasso path as user drags + * - Animated dashed stroke + * - Shows selection count badge + */ + +import { useRef, useCallback, useEffect, useState } from 'react' + +interface LassoPoint { + x: number + y: number +} + +interface LassoOverlayProps { + isDrawing: boolean + points: LassoPoint[] + selectedCount: number + onStartDraw: (x: number, y: number) => void + onMoveDraw: (x: number, y: number) => void + onEndDraw: () => void + onCancelDraw: () => void +} + +export function LassoOverlay({ + isDrawing, + points, + selectedCount, + onStartDraw, + onMoveDraw, + onEndDraw, + onCancelDraw, +}: LassoOverlayProps) { + const overlayRef = useRef(null) + const [isShiftPressed, setIsShiftPressed] = useState(false) + + // Track Shift key state - use state so we can control pointer-events + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setIsShiftPressed(true) + } + if (e.key === 'Escape' && isDrawing) { + onCancelDraw() + } + } + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setIsShiftPressed(false) + // If we were drawing when Shift is released, finish the drawing + if (isDrawing) { + onEndDraw() + } + } + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [isDrawing, onCancelDraw, onEndDraw]) + + // Handle mouse events + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only start if Shift is held (overlay only receives events when shift is pressed) + const rect = overlayRef.current?.getBoundingClientRect() + if (!rect) return + + onStartDraw(e.clientX - rect.left, e.clientY - rect.top) + }, + [onStartDraw] + ) + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isDrawing) return + + const rect = overlayRef.current?.getBoundingClientRect() + if (!rect) return + + onMoveDraw(e.clientX - rect.left, e.clientY - rect.top) + }, + [isDrawing, onMoveDraw] + ) + + const handleMouseUp = useCallback(() => { + if (isDrawing) { + onEndDraw() + } + }, [isDrawing, onEndDraw]) + + // Build SVG path + const pathD = + points.length > 0 + ? points.reduce((d, point, i) => { + if (i === 0) return `M ${point.x} ${point.y}` + return `${d} L ${point.x} ${point.y}` + }, '') + ' Z' // Close the path + : '' + + // Only capture mouse events when shift is pressed or actively drawing + const shouldCaptureMouse = isShiftPressed || isDrawing + + return ( +
+ {/* SVG overlay for drawing */} + {isDrawing && points.length > 0 && ( + + {/* Fill area */} + + {/* Animated dashed border */} + + {/* Points indicator */} + + + )} + + {/* Selection count badge */} + {selectedCount > 0 && !isDrawing && ( +
+ {selectedCount} node{selectedCount !== 1 ? 's' : ''} selected + (Shift+Drag to add more) +
+ )} + + {/* Drawing hint */} + {isDrawing && ( +
+ Release to select nodes • ESC to cancel +
+ )} +
+ ) +} diff --git a/packages/graph-viewer/src/components/MiniMap.tsx b/packages/graph-viewer/src/components/MiniMap.tsx new file mode 100644 index 0000000..99e6d62 --- /dev/null +++ b/packages/graph-viewer/src/components/MiniMap.tsx @@ -0,0 +1,200 @@ +/** + * MiniMap - 2D overview navigator for the 3D graph + * + * Shows a simplified bird's-eye view of all nodes with: + * - Nodes colored by type + * - Current viewport rectangle + * - Click-to-navigate + * - Selected node highlight + */ + +import { useRef, useEffect, useCallback, useMemo } from 'react' +import type { SimulationNode, GraphNode } from '../lib/types' + +interface MiniMapProps { + nodes: SimulationNode[] + selectedNode: GraphNode | null + cameraPosition: { x: number; y: number; z: number } + cameraZoom: number + onNavigate: (x: number, y: number) => void + visible?: boolean + size?: number +} + +// Type colors (simplified from main graph) +const TYPE_COLORS: Record = { + Decision: '#f59e0b', + Pattern: '#8b5cf6', + Preference: '#3b82f6', + Style: '#ec4899', + Habit: '#10b981', + Insight: '#06b6d4', + Context: '#6366f1', + Memory: '#6b7280', +} + +export function MiniMap({ + nodes, + selectedNode, + cameraPosition, + cameraZoom, + onNavigate, + visible = true, + size = 150, +}: MiniMapProps) { + const canvasRef = useRef(null) + const containerRef = useRef(null) + + // Calculate bounds of all nodes + const bounds = useMemo(() => { + if (nodes.length === 0) { + return { minX: -100, maxX: 100, minY: -100, maxY: 100 } + } + + let minX = Infinity, maxX = -Infinity + let minY = Infinity, maxY = -Infinity + + nodes.forEach(node => { + const x = node.x ?? 0 + const y = node.y ?? 0 + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + }) + + // Add padding + const padding = 20 + return { + minX: minX - padding, + maxX: maxX + padding, + minY: minY - padding, + maxY: maxY + padding, + } + }, [nodes]) + + // Convert world coordinates to canvas coordinates + const worldToCanvas = useCallback((x: number, y: number) => { + const rangeX = bounds.maxX - bounds.minX + const rangeY = bounds.maxY - bounds.minY + const scale = Math.max(rangeX, rangeY) + + const canvasX = ((x - bounds.minX) / scale) * size + const canvasY = ((bounds.maxY - y) / scale) * size // Flip Y axis + + return { x: canvasX, y: canvasY } + }, [bounds, size]) + + // Convert canvas coordinates to world coordinates + const canvasToWorld = useCallback((canvasX: number, canvasY: number) => { + const rangeX = bounds.maxX - bounds.minX + const rangeY = bounds.maxY - bounds.minY + const scale = Math.max(rangeX, rangeY) + + const x = (canvasX / size) * scale + bounds.minX + const y = bounds.maxY - (canvasY / size) * scale // Flip Y axis + + return { x, y } + }, [bounds, size]) + + // Draw the mini-map + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !visible) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Clear canvas + ctx.fillStyle = 'rgba(10, 10, 20, 0.85)' + ctx.fillRect(0, 0, size, size) + + // Draw border + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)' + ctx.lineWidth = 1 + ctx.strokeRect(0, 0, size, size) + + // Draw nodes + nodes.forEach(node => { + const pos = worldToCanvas(node.x ?? 0, node.y ?? 0) + const isSelected = selectedNode?.id === node.id + const nodeColor = TYPE_COLORS[node.type] || TYPE_COLORS.Memory + + ctx.beginPath() + ctx.arc(pos.x, pos.y, isSelected ? 4 : 2, 0, Math.PI * 2) + ctx.fillStyle = isSelected ? '#ffffff' : nodeColor + ctx.fill() + + // Glow effect for selected node + if (isSelected) { + ctx.beginPath() + ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2) + ctx.fillStyle = 'rgba(255, 255, 255, 0.2)' + ctx.fill() + } + }) + + // Draw viewport rectangle + const viewportSize = 100 / Math.max(cameraZoom, 0.1) // Rough estimate + const viewCenter = worldToCanvas(cameraPosition.x, cameraPosition.y) + + const halfSize = (viewportSize / (bounds.maxX - bounds.minX)) * size / 2 + const rectSize = Math.min(halfSize * 2, size * 0.8) + + ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)' + ctx.lineWidth = 2 + ctx.strokeRect( + viewCenter.x - rectSize / 2, + viewCenter.y - rectSize / 2, + rectSize, + rectSize + ) + + // Draw camera center dot + ctx.beginPath() + ctx.arc(viewCenter.x, viewCenter.y, 3, 0, Math.PI * 2) + ctx.fillStyle = 'rgba(59, 130, 246, 1)' + ctx.fill() + + }, [nodes, selectedNode, cameraPosition, cameraZoom, bounds, size, visible, worldToCanvas]) + + // Handle click to navigate + const handleClick = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const canvasX = e.clientX - rect.left + const canvasY = e.clientY - rect.top + + const worldPos = canvasToWorld(canvasX, canvasY) + onNavigate(worldPos.x, worldPos.y) + }, [canvasToWorld, onNavigate]) + + if (!visible) return null + + return ( +
+ + {/* Zoom indicator */} +
+ {(cameraZoom * 100).toFixed(0)}% +
+
+ ) +} diff --git a/packages/graph-viewer/src/components/PathfindingOverlay.tsx b/packages/graph-viewer/src/components/PathfindingOverlay.tsx new file mode 100644 index 0000000..dd108a8 --- /dev/null +++ b/packages/graph-viewer/src/components/PathfindingOverlay.tsx @@ -0,0 +1,173 @@ +/** + * PathfindingOverlay - UI for path selection and path info display + * + * Shows: + * - Instructions when selecting target + * - Path info panel when path is found + * - Controls for cycling between alternative paths + */ + +import type { PathResult } from '../hooks/usePathfinding' +import type { SimulationNode } from '../lib/types' + +interface PathfindingOverlayProps { + isSelectingTarget: boolean + sourceNode: SimulationNode | null + targetNode: SimulationNode | null + currentPath: PathResult | null + pathCount: number + activePath: number + onNextPath: () => void + onPreviousPath: () => void + onCancel: () => void + onClear: () => void + visible?: boolean +} + +export function PathfindingOverlay({ + isSelectingTarget, + sourceNode, + targetNode, + currentPath, + pathCount, + activePath, + onNextPath, + onPreviousPath, + onCancel, + onClear, + visible = true, +}: PathfindingOverlayProps) { + if (!visible) return null + + // Show selection prompt when selecting target + if (isSelectingTarget && sourceNode) { + return ( +
+
+
+
+ + + +
+
+
Select destination node
+
+ Click another node to find path from "{truncate(sourceNode.content, 30)}" +
+
+ +
+
+
+ ) + } + + // 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..612147d --- /dev/null +++ b/packages/graph-viewer/src/components/RadialMenu.tsx @@ -0,0 +1,272 @@ +/** + * 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, + 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 + onStartPath?: (nodeId: string) => void + onAddToSelection?: (node: GraphNode) => void + onEdit?: (node: GraphNode) => void + onViewContent?: (node: GraphNode) => void + onCopyId?: (nodeId: string) => void + onDelete?: (node: GraphNode) => void +} + +export function RadialMenu({ + node, + position, + onClose, + onFindSimilar, + onStartPath, + onAddToSelection, + onEdit, + onViewContent, + onCopyId, + onDelete, +}: 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 (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: '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/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/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/SelectionHighlight.tsx b/packages/graph-viewer/src/components/SelectionHighlight.tsx new file mode 100644 index 0000000..edb87c6 --- /dev/null +++ b/packages/graph-viewer/src/components/SelectionHighlight.tsx @@ -0,0 +1,333 @@ +import { useRef, useMemo, useState, useEffect } from 'react' +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import type { SimulationNode, GraphNode } from '../lib/types' + +/** + * Visual feedback for direct pinch selection ("pick the berry") + * Shows a ring that tightens as pinch strength increases + */ +interface PinchPreSelectHighlightProps { + node: SimulationNode | null + pinchStrength: number // 0-1: how close to pinching + color?: string +} + +export function PinchPreSelectHighlight({ + node, + pinchStrength, + color, +}: PinchPreSelectHighlightProps) { + const ringRef = useRef(null) + const innerRingRef = useRef(null) + const [prevNode, setPrevNode] = useState(null) + const fadeRef = useRef(0) + + // Smooth fade in/out when node changes + useEffect(() => { + if (node !== prevNode) { + setPrevNode(node) + } + }, [node, prevNode]) + + // Animate the ring based on pinch strength + useFrame((state, delta) => { + // Fade in/out + const targetFade = node ? 1 : 0 + fadeRef.current += (targetFade - fadeRef.current) * Math.min(1, delta * 8) + + if (fadeRef.current < 0.01) return + if (!ringRef.current || !innerRingRef.current) return + + const t = state.clock.elapsedTime + + // Outer ring: pulses gently + const outerMaterial = ringRef.current.material as THREE.MeshBasicMaterial + const pulse = 0.4 + Math.sin(t * 3) * 0.1 + outerMaterial.opacity = pulse * fadeRef.current + + // Slow rotation + ringRef.current.rotation.z = t * 0.5 + + // Inner ring: tightens based on pinch strength + // At 0 strength, inner ring is at same size as outer + // At 1 strength (pinched), inner ring contracts to node size + const innerMaterial = innerRingRef.current.material as THREE.MeshBasicMaterial + const contractAmount = pinchStrength * 0.7 // How much it contracts (0-0.7) + innerRingRef.current.scale.setScalar(1 - contractAmount) + + // Inner ring gets brighter and more opaque as pinch increases + innerMaterial.opacity = (0.3 + pinchStrength * 0.5) * fadeRef.current + + // Opposite rotation for visual interest + innerRingRef.current.rotation.z = -t * 0.8 + }) + + // Use the node that's fading (current or previous) + const displayNode = node || prevNode + if (!displayNode || fadeRef.current < 0.01) return null + + const nodeColor = color || '#fbbf24' // Amber/gold for pre-select + const nodeRadius = displayNode.radius || 3 + + // Ring sizes + const innerRadius = nodeRadius * 1.3 + const outerRadius = nodeRadius * 2.2 + + return ( + + {/* Outer pulsing ring */} + + + + + + {/* Inner contracting ring - shows pinch progress */} + + + + + + ) +} + +interface SelectionHighlightProps { + node: SimulationNode | null + color?: string + innerRadius?: number + outerRadius?: number +} + +/** + * Animated glowing ring around the selected node + * Creates an Obsidian-like focus effect + */ +export function SelectionHighlight({ + node, + color, + innerRadius = 1.2, + outerRadius = 1.8, +}: SelectionHighlightProps) { + const ringRef = useRef(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 ( + + + + + + + + ) +} 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/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/TokenPrompt.tsx b/packages/graph-viewer/src/components/TokenPrompt.tsx new file mode 100644 index 0000000..7b5087f --- /dev/null +++ b/packages/graph-viewer/src/components/TokenPrompt.tsx @@ -0,0 +1,144 @@ +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 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) + + 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 + +

+
+
+ ) +} 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..23524cb --- /dev/null +++ b/packages/graph-viewer/src/components/settings/SettingsPanel.tsx @@ -0,0 +1,462 @@ +import { X, RotateCcw, Zap, Volume2 } 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 + // Audio settings + soundEnabled: boolean + onSoundEnabledChange: (enabled: boolean) => void + soundVolume: number + onSoundVolumeChange: (volume: number) => void +} + +export function SettingsPanel({ + isOpen, + onClose, + filters, + onFiltersChange, + typeColors = {}, + forceConfig, + onForceConfigChange, + onReheat, + onResetForces, + displayConfig, + onDisplayConfigChange, + clusterConfig, + onClusterConfigChange, + relationshipVisibility, + onRelationshipVisibilityChange, + soundEnabled, + onSoundEnabledChange, + soundVolume, + onSoundVolumeChange, +}: 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)} + /> + +
+ +
+ {[250, 500, 1000, 2000, 5000].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 */} + + {/* UMAP Toggle - semantic embedding layout */} + onClusterConfigChange({ useUMAP: v })} + description="Position nodes by embedding similarity (slower load)" + /> + + {clusterConfig.useUMAP && ( +
+ UMAP active - nodes positioned by semantic similarity from embeddings. + First load may take 5-15 seconds. +
+ )} + +
+ +
+ {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 })} + /> + +
+ + +
+
+ + {/* 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/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/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/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/useClusterDetection.ts b/packages/graph-viewer/src/hooks/useClusterDetection.ts new file mode 100644 index 0000000..2fae9d5 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useClusterDetection.ts @@ -0,0 +1,198 @@ +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 shared tags - nodes sharing ANY tag cluster together + // Priority: entity tags > project tags > other tags + const tagToNodes = new Map() + + for (const node of nodes) { + // Find best clustering tag (prefer entity: tags, then project-like tags) + let bestTag = 'untagged' + for (const tag of node.tags) { + if (tag.startsWith('entity:')) { + // Entity tags are highest priority (entity:people:jack, entity:organizations:automem) + bestTag = tag + break + } else if (!bestTag.startsWith('entity:') && !tag.match(/^\d{4}-\d{2}$/) && tag !== 'cursor') { + // Skip date tags (2025-12) and generic platform tags + bestTag = tag + } + } + + if (!tagToNodes.has(bestTag)) { + tagToNodes.set(bestTag, []) + } + tagToNodes.get(bestTag)!.push(node) + } + + // Transfer to nodeGroups + for (const [tag, tagNodes] of tagToNodes) { + nodeGroups.set(tag, tagNodes) + } + } 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.3 (lowered for tighter clusters) + const adj = new Map() + for (const edge of edges) { + if (edge.strength >= 0.25) { + // 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 +} 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/useForceLayout.ts b/packages/graph-viewer/src/hooks/useForceLayout.ts new file mode 100644 index 0000000..bc8b3cb --- /dev/null +++ b/packages/graph-viewer/src/hooks/useForceLayout.ts @@ -0,0 +1,307 @@ +import { useState, useCallback, useMemo } from 'react' +import { + forceSimulation, + forceLink, + forceManyBody, + forceCenter, + forceCollide, + forceRadial, +} from 'd3-force-3d' +import type { + GraphNode, + GraphEdge, + SimulationNode, + SimulationLink, + ForceConfig, + ClusterMode, +} from '../lib/types' +import { DEFAULT_FORCE_CONFIG } from '../lib/types' + +interface UseForceLayoutOptions { + nodes: GraphNode[] + edges: GraphEdge[] + forceConfig?: ForceConfig + useServerPositions?: boolean // If true, use pre-computed x,y,z from server (UMAP) + seedMode?: ClusterMode +} + +interface LayoutState { + nodes: SimulationNode[] + isSimulating: boolean +} + +// 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, + seedMode: 'tags' as ClusterMode, +} + +// 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}` +} + +// Get primary clustering tag for a node (prioritize entity tags over generic ones) +function getPrimaryTag(node: GraphNode): string { + for (const tag of node.tags) { + if (tag.startsWith('entity:')) return tag + } + for (const tag of node.tags) { + if (!tag.match(/^\d{4}-\d{2}$/) && tag !== 'cursor') return tag + } + return node.tags[0] || 'untagged' +} + +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 < 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 group clusters + + return { + tx: radius * Math.cos(angle), + ty: radius * Math.sin(angle), + tz: (Math.abs(hash) % 100) - 50, // -50 to 50 z offset + } +} + +// 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[], + seedMode: ClusterMode +): SimulationNode[] { + 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 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) { + return { + ...node, + x: existing.x, + y: existing.y, + z: existing.z, + vx: existing.vx || 0, + vy: existing.vy || 0, + vz: existing.vz || 0, + } + } + + 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 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, + y, + z, + 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 (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 +} + +export function useForceLayout({ + nodes, + edges, + forceConfig = DEFAULT_FORCE_CONFIG, + useServerPositions = false, + seedMode = 'tags', +}: UseForceLayoutOptions): LayoutState & { reheat: () => void } { + const [isSimulating, setIsSimulating] = useState(false) + + // 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 [] + } + + // If server provided positions (UMAP), use them directly without force simulation + if (useServerPositions) { + const hasPositions = nodes.every((n) => n.x !== undefined && n.y !== undefined && n.z !== undefined) + if (hasPositions) { + const serverNodes: SimulationNode[] = nodes.map((node) => ({ + ...node, + x: node.x!, + y: node.y!, + z: node.z!, + vx: 0, + vy: 0, + vz: 0, + })) + // Update cache with server-provided positions + layoutCache.signature = createDataSignature(nodes) + '-server' + layoutCache.nodes = serverNodes + layoutCache.seedMode = seedMode + return serverNodes + } + } + + const signature = createDataSignature(nodes) + + // Check cache - if signature matches, return cached nodes + if ( + signature === layoutCache.signature && + layoutCache.nodes.length > 0 && + layoutCache.seedMode === seedMode + ) { + return layoutCache.nodes + } + + // Compute new layout using force simulation + 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, seedMode]) + + // Reheat function uses module-level cache + const reheat = useCallback(() => { + if (layoutCache.simulation) { + layoutCache.simulation.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..efac9de --- /dev/null +++ b/packages/graph-viewer/src/hooks/useGraphData.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query' +import { + fetchGraphSnapshot, + fetchGraphNeighbors, + fetchGraphStats, + fetchProjectedGraph, + type SnapshotParams, + type NeighborsParams, + type ProjectedParams, +} 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 useProjectedGraph(params: ProjectedParams & { enabled?: boolean } = {}) { + const { enabled = true, ...queryParams } = params + + return useQuery({ + queryKey: ['graph', 'projected', queryParams], + queryFn: () => fetchProjectedGraph(queryParams), + enabled, + staleTime: 1000 * 60 * 5, // Cache UMAP projections for 5 mins (expensive to compute) + }) +} + +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/hooks/useHandGestures.ts b/packages/graph-viewer/src/hooks/useHandGestures.ts new file mode 100644 index 0000000..97f5bc8 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandGestures.ts @@ -0,0 +1,408 @@ +/** + * 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 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 + 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 + + // Direct pinch selection point (midpoint between thumb tip and index tip) + // Used for "pick the berry" selection - position this over a node and pinch + pinchPoint: { x: number; y: number } | null // Normalized screen coords (0-1) + + // Pinch ray for laser pointer (Meta Quest style) - DEPRECATED, use pinchPoint instead + 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 + 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, + pinchPoint: null, + leftPinchRay: null, + rightPinchRay: null, + activePinchRay: null, + 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 mirrorXLandmarks(landmarks: NormalizedLandmarkList): NormalizedLandmarkList { + // Mirror landmarks horizontally so the UI behaves like a webcam "self view": + // moving your right hand to the right moves the on-screen hand to the right. + return landmarks.map((lm) => ({ ...lm, x: 1 - lm.x })) +} + +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 } +} + +// 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 + + 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) + const isCleaningUpRef = useRef(false) // Prevent send() after close() + + // 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 = mirrorXLandmarks(results.multiHandLandmarks[i]) + const worldLandmarks = results.multiHandWorldLandmarks?.[i] || landmarks + const handedness = results.multiHandedness[i].label as 'Left' | 'Right' + + const handData: HandLandmarks = { + landmarks, + worldLandmarks, + handedness, + } + + 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] + 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 + + isCleaningUpRef.current = false + + 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 () => { + // Guard against calling send() after close() + if (isCleaningUpRef.current) return + if (handsRef.current && 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, + height: 480, + }) + + cameraRef.current = camera + await camera.start() + isInitializedRef.current = true + } + + 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 + } + }, [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 diff --git a/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts new file mode 100644 index 0000000..62d5ce2 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandLockAndGrab.ts @@ -0,0 +1,475 @@ +/** + * Hand Grab + Pinch Control (no explicit locking) + * + * 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 { 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: pointing pose score (index extended, others curled) */ + point: number + /** 0..1: pinch strength (thumb-index) */ + pinch: number + /** Thumb-index midpoint in normalized screen coords (0..1) */ + pinchPoint: { x: number; y: 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'; hand: HandSide; 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 + /** is pinch currently activated (for selection) */ + pinchActivated: boolean + /** frames that acquire pose has been held (for clear selection gesture) */ + clearHoldFrames: number + } + +export interface CloudControlDeltas { + /** zoom velocity (positive -> zoom in, negative -> zoom out) */ + zoom: 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 SPREAD_THRESHOLD = 0.78 +const PALM_FACING_THRESHOLD = 0.72 + +const GRAB_ON_THRESHOLD = 0.72 +const GRAB_OFF_THRESHOLD = 0.45 + +// Pinch thresholds for direct selection ("pick the berry") +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.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 + +// Control sensitivity +const DEPTH_DEADZONE = 0.01 + +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 +} + +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 }, + 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. + */ +function computeMetrics(state: GestureState, hand: HandSide): HandLockMetrics | null { + const handData = hand === 'right' ? state.rightHand : state.leftHand + if (!handData) return null + + const lm = handData.landmarks + const wm = handData.worldLandmarks || lm + // Required joints + 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] + 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 + + // Pinch (thumb-index) + const pinchRay = hand === 'right' ? state.rightPinchRay : state.leftPinchRay + const pinchDist = length2(thumbTip.x - indexTip.x, thumbTip.y - indexTip.y) + const pinch2d = clamp(1 - safeDiv(pinchDist - 0.02, 0.13), 0, 1) + const pinch = clamp((pinchRay?.strength ?? pinch2d) as number, 0, 1) + const pinchPoint = { + x: (thumbTip.x + indexTip.x) / 2, + y: (thumbTip.y + indexTip.y) / 2, + } + + // 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) + // 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 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 + // 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.55) grab = 0 + + // Depth: prefer pinch ray origin z when present (iPhone LiDAR mapped into landmarks z) + 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, point, pinch, pinchPoint, grab, depth, confidence } +} + +function isAcquirePose(m: HandLockMetrics) { + // 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 interface HandLockResult { + lock: HandLockState + deltas: CloudControlDeltas + /** True when user holds acquire pose to clear selection */ + clearRequested: boolean + /** True when both hands are pinching (for bimanual manipulation) */ + bimanualPinch: boolean + /** Metrics for left hand (for two-hand gestures) */ + leftMetrics: HandLockMetrics | null + /** Metrics for right hand (for two-hand gestures) */ + rightMetrics: HandLockMetrics | null +} + +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 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 + + 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 + } + + bimanualPinchRef.current = bimanualPinch + + const noDeltas: CloudControlDeltas = { zoom: 0, panX: 0, panY: 0, panZ: 0, grabStart: false } + + 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 } + } + + // --- 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' + } + + 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 + } + + // 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 (!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 = locked + + return { + lock: lockRef.current, + deltas, + clearRequested, + bimanualPinch, + leftMetrics: left, + rightMetrics: right, + } +} diff --git a/packages/graph-viewer/src/hooks/useHandPlayback.ts b/packages/graph-viewer/src/hooks/useHandPlayback.ts new file mode 100644 index 0000000..e25fb58 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandPlayback.ts @@ -0,0 +1,440 @@ +/** + * Hand Playback Hook + * + * Replays recorded hand tracking data for automated testing. + * Bypasses the WebSocket connection and injects frames directly. + * + * Usage: + * 1. Load a recording from localStorage or file + * 2. Use the returned gestureState in place of real hand tracking + * 3. Control playback with play/pause/seek + * + * For Chrome automation: + * - Expose window.__handPlayback for external control + * - Listen to console logs for gesture events + */ + +import { useState, useRef, useCallback, useEffect } from 'react' +import type { GestureState, HandLandmarks } from './useHandGestures' +import type { NormalizedLandmarkList } from '@mediapipe/hands' +import type { HandRecording, RecordedFrame } from './useHandRecording' + +export interface PlaybackState { + isPlaying: boolean + isPaused: boolean + currentTime: number // ms + duration: number // ms + currentFrame: number + totalFrames: number + speed: number // 1.0 = normal, 0.5 = half, 2.0 = double + isLooped: boolean + recordingName: string +} + +export interface UseHandPlaybackOptions { + /** Callback when gesture state changes */ + onGestureChange?: (state: GestureState) => void + /** Callback when playback ends */ + onPlaybackEnd?: () => void + /** Log gesture events to console for automation */ + logEvents?: boolean + /** Expose window.__handPlayback for external control */ + exposeGlobal?: boolean +} + +// Default empty gesture state +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, + pinchPoint: null, + leftPinchRay: null, + rightPinchRay: null, + activePinchRay: null, + zoomDelta: 0, + rotateDelta: 0, + panDelta: { x: 0, y: 0 }, +} + +export function useHandPlayback(options: UseHandPlaybackOptions = {}) { + const { onGestureChange, onPlaybackEnd, logEvents = true, exposeGlobal = true } = options + + const [state, setState] = useState({ + isPlaying: false, + isPaused: false, + currentTime: 0, + duration: 0, + currentFrame: 0, + totalFrames: 0, + speed: 1.0, + isLooped: false, + recordingName: '', + }) + + const [gestureState, setGestureState] = useState(DEFAULT_STATE) + + const recordingRef = useRef(null) + const animationFrameRef = useRef(0) + const playbackStartRef = useRef(0) + const pausedAtRef = useRef(0) + const prevGestureRef = useRef>({}) + + // Convert recorded frame to gesture state + const frameToGestureState = useCallback((frame: RecordedFrame): GestureState => { + const convertLandmarks = (landmarks: { x: number; y: number; z: number; visibility?: number }[] | null): NormalizedLandmarkList | null => { + if (!landmarks) return null + return landmarks.map((lm) => ({ + x: lm.x, + y: lm.y, + z: lm.z, + visibility: lm.visibility ?? 1, + })) + } + + const leftLandmarks = convertLandmarks(frame.leftLandmarks) + const rightLandmarks = convertLandmarks(frame.rightLandmarks) + const leftWorldLandmarks = convertLandmarks(frame.leftWorldLandmarks) + const rightWorldLandmarks = convertLandmarks(frame.rightWorldLandmarks) + + const leftHand: HandLandmarks | null = leftLandmarks + ? { landmarks: leftLandmarks, worldLandmarks: leftWorldLandmarks || leftLandmarks, handedness: 'Left' } + : null + + const rightHand: HandLandmarks | null = rightLandmarks + ? { landmarks: rightLandmarks, worldLandmarks: rightWorldLandmarks || rightLandmarks, handedness: 'Right' } + : null + + return { + ...DEFAULT_STATE, + isTracking: frame.handsDetected > 0, + handsDetected: frame.handsDetected, + leftHand, + rightHand, + pinchStrength: frame.metrics.pinchStrength, + grabStrength: frame.metrics.grabStrength, + // Restore other fields from recorded gesture state + pointingHand: frame.gestureState.pointingHand ?? null, + pointDirection: frame.gestureState.pointDirection ?? null, + twoHandDistance: frame.gestureState.twoHandDistance ?? 0.5, + twoHandRotation: frame.gestureState.twoHandRotation ?? 0, + twoHandCenter: frame.gestureState.twoHandCenter ?? { x: 0.5, y: 0.5 }, + } + }, []) + + // Find frame at given time + const getFrameAtTime = useCallback((time: number): RecordedFrame | null => { + const recording = recordingRef.current + if (!recording || recording.frames.length === 0) return null + + // Binary search for efficiency + const frames = recording.frames + let left = 0 + let right = frames.length - 1 + + while (left < right) { + const mid = Math.floor((left + right) / 2) + if (frames[mid].timestamp < time) { + left = mid + 1 + } else { + right = mid + } + } + + // Return closest frame + if (left > 0 && time - frames[left - 1].timestamp < frames[left].timestamp - time) { + return frames[left - 1] + } + return frames[left] + }, []) + + // Log gesture events for automation + const logGestureEvent = useCallback( + (newState: GestureState) => { + if (!logEvents) return + + const prev = prevGestureRef.current + + // Detect state changes + if (newState.pinchStrength > 0.85 && (prev.pinchStrength ?? 0) <= 0.85) { + console.log('[GESTURE] PINCH_START', { strength: newState.pinchStrength }) + } + if (newState.pinchStrength < 0.65 && (prev.pinchStrength ?? 0) >= 0.65) { + console.log('[GESTURE] PINCH_END', { strength: newState.pinchStrength }) + } + if (newState.grabStrength > 0.72 && (prev.grabStrength ?? 0) <= 0.72) { + console.log('[GESTURE] GRAB_START', { strength: newState.grabStrength }) + } + if (newState.grabStrength < 0.45 && (prev.grabStrength ?? 0) >= 0.45) { + console.log('[GESTURE] GRAB_END', { strength: newState.grabStrength }) + } + if (newState.pointingHand && !prev.pointingHand) { + console.log('[GESTURE] POINT_START', { hand: newState.pointingHand, direction: newState.pointDirection }) + } + if (!newState.pointingHand && prev.pointingHand) { + console.log('[GESTURE] POINT_END') + } + + prevGestureRef.current = { + pinchStrength: newState.pinchStrength, + grabStrength: newState.grabStrength, + pointingHand: newState.pointingHand, + } + }, + [logEvents] + ) + + // Playback loop + const tick = useCallback(() => { + if (!state.isPlaying || state.isPaused) return + + const recording = recordingRef.current + if (!recording) return + + const now = performance.now() + const elapsed = (now - playbackStartRef.current) * state.speed + let currentTime = elapsed + + // Handle looping + if (currentTime >= recording.metadata.duration) { + if (state.isLooped) { + playbackStartRef.current = now + currentTime = 0 + } else { + // End playback + setState((prev) => ({ + ...prev, + isPlaying: false, + currentTime: recording.metadata.duration, + currentFrame: recording.frames.length - 1, + })) + setGestureState(DEFAULT_STATE) + onPlaybackEnd?.() + console.log('[PLAYBACK] END') + return + } + } + + // Get frame at current time + const frame = getFrameAtTime(currentTime) + if (frame) { + const newGestureState = frameToGestureState(frame) + setGestureState(newGestureState) + onGestureChange?.(newGestureState) + logGestureEvent(newGestureState) + + const frameIndex = recording.frames.indexOf(frame) + setState((prev) => ({ + ...prev, + currentTime, + currentFrame: frameIndex, + })) + } + + animationFrameRef.current = requestAnimationFrame(tick) + }, [state.isPlaying, state.isPaused, state.speed, state.isLooped, getFrameAtTime, frameToGestureState, onGestureChange, onPlaybackEnd, logGestureEvent]) + + // Start playback + const play = useCallback(() => { + const recording = recordingRef.current + if (!recording) { + console.warn('[PLAYBACK] No recording loaded') + return + } + + if (state.isPaused) { + // Resume from pause + playbackStartRef.current = performance.now() - pausedAtRef.current / state.speed + } else { + // Start fresh + playbackStartRef.current = performance.now() + } + + setState((prev) => ({ + ...prev, + isPlaying: true, + isPaused: false, + })) + + console.log('[PLAYBACK] START', recording.metadata.name) + animationFrameRef.current = requestAnimationFrame(tick) + }, [state.isPaused, state.speed, tick]) + + // Pause playback + const pause = useCallback(() => { + cancelAnimationFrame(animationFrameRef.current) + pausedAtRef.current = state.currentTime + + setState((prev) => ({ + ...prev, + isPaused: true, + })) + + console.log('[PLAYBACK] PAUSE at', state.currentTime) + }, [state.currentTime]) + + // Stop playback + const stop = useCallback(() => { + cancelAnimationFrame(animationFrameRef.current) + + setState((prev) => ({ + ...prev, + isPlaying: false, + isPaused: false, + currentTime: 0, + currentFrame: 0, + })) + + setGestureState(DEFAULT_STATE) + console.log('[PLAYBACK] STOP') + }, []) + + // Seek to time + const seek = useCallback( + (time: number) => { + const recording = recordingRef.current + if (!recording) return + + const clampedTime = Math.max(0, Math.min(time, recording.metadata.duration)) + const frame = getFrameAtTime(clampedTime) + + if (frame) { + const newGestureState = frameToGestureState(frame) + setGestureState(newGestureState) + onGestureChange?.(newGestureState) + + const frameIndex = recording.frames.indexOf(frame) + setState((prev) => ({ + ...prev, + currentTime: clampedTime, + currentFrame: frameIndex, + })) + + if (state.isPlaying && !state.isPaused) { + playbackStartRef.current = performance.now() - clampedTime / state.speed + } else { + pausedAtRef.current = clampedTime + } + } + + console.log('[PLAYBACK] SEEK to', clampedTime) + }, + [getFrameAtTime, frameToGestureState, onGestureChange, state.isPlaying, state.isPaused, state.speed] + ) + + // Load recording + const loadRecording = useCallback((recording: HandRecording) => { + cancelAnimationFrame(animationFrameRef.current) + recordingRef.current = recording + prevGestureRef.current = {} + + setState({ + isPlaying: false, + isPaused: false, + currentTime: 0, + duration: recording.metadata.duration, + currentFrame: 0, + totalFrames: recording.frames.length, + speed: 1.0, + isLooped: false, + recordingName: recording.metadata.name, + }) + + setGestureState(DEFAULT_STATE) + console.log('[PLAYBACK] LOADED', recording.metadata.name, recording.frames.length, 'frames') + }, []) + + // Set playback speed + const setSpeed = useCallback((speed: number) => { + setState((prev) => ({ + ...prev, + speed: Math.max(0.1, Math.min(5.0, speed)), + })) + }, []) + + // Set loop mode + const setLooped = useCallback((looped: boolean) => { + setState((prev) => ({ ...prev, isLooped: looped })) + }, []) + + // Cleanup on unmount + useEffect(() => { + return () => { + cancelAnimationFrame(animationFrameRef.current) + } + }, []) + + // Effect to continue playback loop + useEffect(() => { + if (state.isPlaying && !state.isPaused) { + animationFrameRef.current = requestAnimationFrame(tick) + } + return () => { + cancelAnimationFrame(animationFrameRef.current) + } + }, [state.isPlaying, state.isPaused, tick]) + + // Expose global interface for Chrome automation + useEffect(() => { + if (exposeGlobal) { + const api = { + loadRecording, + play, + pause, + stop, + seek, + setSpeed, + setLooped, + getState: () => state, + getGestureState: () => gestureState, + getRecording: () => recordingRef.current, + } + ;(window as unknown as Record).__handPlayback = api + console.log('[PLAYBACK] Exposed window.__handPlayback for automation') + } + + return () => { + if (exposeGlobal) { + delete (window as unknown as Record).__handPlayback + } + } + }, [exposeGlobal, loadRecording, play, pause, stop, seek, setSpeed, setLooped, state, gestureState]) + + return { + // Current gesture state (use this instead of real hand tracking) + gestureState, + + // Playback state + isPlaying: state.isPlaying, + isPaused: state.isPaused, + currentTime: state.currentTime, + duration: state.duration, + currentFrame: state.currentFrame, + totalFrames: state.totalFrames, + speed: state.speed, + isLooped: state.isLooped, + recordingName: state.recordingName, + + // Controls + loadRecording, + play, + pause, + stop, + seek, + setSpeed, + setLooped, + + // Computed + progress: state.duration > 0 ? state.currentTime / state.duration : 0, + hasRecording: recordingRef.current !== null, + } +} + +export default useHandPlayback diff --git a/packages/graph-viewer/src/hooks/useHandRecording.ts b/packages/graph-viewer/src/hooks/useHandRecording.ts new file mode 100644 index 0000000..cf380fb --- /dev/null +++ b/packages/graph-viewer/src/hooks/useHandRecording.ts @@ -0,0 +1,364 @@ +/** + * Hand Recording Hook + * + * Records hand tracking data for playback and automated testing. + * Captures raw landmarks, computed metrics, and gesture state. + * + * Usage: + * 1. Press 'R' to start/stop recording + * 2. Recordings are saved to localStorage and can be downloaded as JSON + * 3. Use useHandPlayback to replay recordings without iPhone + */ + +import { useState, useRef, useCallback, useEffect } from 'react' +import type { GestureState } from './useHandGestures' + +// Recording data structures +export interface RecordedLandmark { + x: number + y: number + z: number + visibility?: number +} + +export interface RecordedMetrics { + pinchStrength: number + grabStrength: number + spreadAmount?: number + palmFacing?: number + pointScore?: number +} + +export interface RecordedFrame { + timestamp: number // ms from recording start + leftLandmarks: RecordedLandmark[] | null + rightLandmarks: RecordedLandmark[] | null + leftWorldLandmarks: RecordedLandmark[] | null + rightWorldLandmarks: RecordedLandmark[] | null + metrics: RecordedMetrics + hasLiDARDepth: boolean + handsDetected: number + // Full gesture state for validation + gestureState: Partial +} + +export interface HandRecording { + metadata: { + id: string + name: string + description: string + recordedAt: string + duration: number // ms + frameCount: number + avgFps: number + } + frames: RecordedFrame[] +} + +export interface UseHandRecordingOptions { + /** Maximum recording duration in ms. Default: 60000 (1 minute) */ + maxDuration?: number + /** Keyboard key to toggle recording. Default: 'r' */ + toggleKey?: string + /** Auto-download JSON when recording stops. Default: false */ + autoDownload?: boolean +} + +interface RecordingState { + isRecording: boolean + isPaused: boolean + duration: number + frameCount: number + recordingName: string +} + +export function useHandRecording(options: UseHandRecordingOptions = {}) { + const { maxDuration = 60000, toggleKey = 'r', autoDownload = false } = options + + const [state, setState] = useState({ + isRecording: false, + isPaused: false, + duration: 0, + frameCount: 0, + recordingName: '', + }) + + const framesRef = useRef([]) + const startTimeRef = useRef(0) + const recordingIdRef = useRef('') + + // Start a new recording + const startRecording = useCallback((name?: string) => { + const id = `rec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + recordingIdRef.current = id + framesRef.current = [] + startTimeRef.current = Date.now() + + setState({ + isRecording: true, + isPaused: false, + duration: 0, + frameCount: 0, + recordingName: name || `Recording ${new Date().toLocaleTimeString()}`, + }) + + console.log('🔴 Recording started:', id) + }, []) + + // Stop recording and return the data + const stopRecording = useCallback((): HandRecording | null => { + if (!state.isRecording) return null + + const endTime = Date.now() + const duration = endTime - startTimeRef.current + const frames = framesRef.current + const avgFps = frames.length > 0 ? (frames.length / duration) * 1000 : 0 + + const recording: HandRecording = { + metadata: { + id: recordingIdRef.current, + name: state.recordingName, + description: '', + recordedAt: new Date(startTimeRef.current).toISOString(), + duration, + frameCount: frames.length, + avgFps: Math.round(avgFps * 10) / 10, + }, + frames, + } + + // Save to localStorage + const key = `hand_recording_${recordingIdRef.current}` + try { + localStorage.setItem(key, JSON.stringify(recording)) + console.log('💾 Recording saved to localStorage:', key) + } catch (e) { + console.warn('Failed to save to localStorage:', e) + } + + setState({ + isRecording: false, + isPaused: false, + duration: 0, + frameCount: 0, + recordingName: '', + }) + + console.log('⏹️ Recording stopped:', frames.length, 'frames,', duration, 'ms') + + // Auto-download if enabled + if (autoDownload) { + downloadRecording(recording) + } + + return recording + }, [state.isRecording, state.recordingName, autoDownload]) + + // Record a single frame + const recordFrame = useCallback( + (gestureState: GestureState, hasLiDARDepth: boolean = false) => { + if (!state.isRecording || state.isPaused) return + + const now = Date.now() + const elapsed = now - startTimeRef.current + + // Check max duration + if (elapsed > maxDuration) { + stopRecording() + return + } + + const frame: RecordedFrame = { + timestamp: elapsed, + leftLandmarks: gestureState.leftHand?.landmarks.map((lm) => ({ + x: lm.x, + y: lm.y, + z: lm.z ?? 0, + visibility: lm.visibility, + })) ?? null, + rightLandmarks: gestureState.rightHand?.landmarks.map((lm) => ({ + x: lm.x, + y: lm.y, + z: lm.z ?? 0, + visibility: lm.visibility, + })) ?? null, + leftWorldLandmarks: gestureState.leftHand?.worldLandmarks?.map((lm) => ({ + x: lm.x, + y: lm.y, + z: lm.z ?? 0, + visibility: lm.visibility, + })) ?? null, + rightWorldLandmarks: gestureState.rightHand?.worldLandmarks?.map((lm) => ({ + x: lm.x, + y: lm.y, + z: lm.z ?? 0, + visibility: lm.visibility, + })) ?? null, + metrics: { + pinchStrength: gestureState.pinchStrength, + grabStrength: gestureState.grabStrength, + }, + hasLiDARDepth, + handsDetected: gestureState.handsDetected, + // Include relevant gesture state fields + gestureState: { + isTracking: gestureState.isTracking, + handsDetected: gestureState.handsDetected, + pointingHand: gestureState.pointingHand, + pointDirection: gestureState.pointDirection, + pinchStrength: gestureState.pinchStrength, + grabStrength: gestureState.grabStrength, + twoHandDistance: gestureState.twoHandDistance, + twoHandRotation: gestureState.twoHandRotation, + twoHandCenter: gestureState.twoHandCenter, + }, + } + + framesRef.current.push(frame) + + // Update state periodically (every 10 frames to reduce re-renders) + if (framesRef.current.length % 10 === 0) { + setState((prev) => ({ + ...prev, + duration: elapsed, + frameCount: framesRef.current.length, + })) + } + }, + [state.isRecording, state.isPaused, maxDuration, stopRecording] + ) + + // Toggle recording on/off + const toggleRecording = useCallback(() => { + if (state.isRecording) { + return stopRecording() + } else { + startRecording() + return null + } + }, [state.isRecording, startRecording, stopRecording]) + + // Pause/resume + const pauseRecording = useCallback(() => { + if (state.isRecording) { + setState((prev) => ({ ...prev, isPaused: true })) + } + }, [state.isRecording]) + + const resumeRecording = useCallback(() => { + if (state.isRecording && state.isPaused) { + setState((prev) => ({ ...prev, isPaused: false })) + } + }, [state.isRecording, state.isPaused]) + + // Keyboard shortcut + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + if (e.key.toLowerCase() === toggleKey.toLowerCase() && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + toggleRecording() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleKey, toggleRecording]) + + return { + // State + isRecording: state.isRecording, + isPaused: state.isPaused, + duration: state.duration, + frameCount: state.frameCount, + recordingName: state.recordingName, + + // Actions + startRecording, + stopRecording, + toggleRecording, + pauseRecording, + resumeRecording, + recordFrame, + + // Utilities + setRecordingName: (name: string) => + setState((prev) => ({ ...prev, recordingName: name })), + } +} + +// Utility functions + +export function downloadRecording(recording: HandRecording): void { + const json = JSON.stringify(recording, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + + const a = document.createElement('a') + a.href = url + a.download = `${recording.metadata.name.replace(/[^a-z0-9]/gi, '_')}_${recording.metadata.id}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + console.log('📥 Downloaded:', a.download) +} + +export function loadRecordingFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => { + try { + const recording = JSON.parse(e.target?.result as string) as HandRecording + resolve(recording) + } catch (err) { + reject(new Error('Invalid recording file')) + } + } + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsText(file) + }) +} + +export function listSavedRecordings(): { key: string; metadata: HandRecording['metadata'] }[] { + const recordings: { key: string; metadata: HandRecording['metadata'] }[] = [] + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key?.startsWith('hand_recording_')) { + try { + const data = JSON.parse(localStorage.getItem(key) || '{}') as HandRecording + if (data.metadata) { + recordings.push({ key, metadata: data.metadata }) + } + } catch { + // Ignore invalid entries + } + } + } + + // Sort by date, newest first + return recordings.sort( + (a, b) => new Date(b.metadata.recordedAt).getTime() - new Date(a.metadata.recordedAt).getTime() + ) +} + +export function loadRecordingFromStorage(key: string): HandRecording | null { + try { + const data = localStorage.getItem(key) + return data ? (JSON.parse(data) as HandRecording) : null + } catch { + return null + } +} + +export function deleteRecordingFromStorage(key: string): void { + localStorage.removeItem(key) +} + +export default useHandRecording diff --git a/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts new file mode 100644 index 0000000..90c98d3 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts @@ -0,0 +1,591 @@ +/** + * iPhone Hand Tracking Hook + * + * Receives hand landmark data from iPhone via WebSocket. + * Converts to the same format as MediaPipe for seamless integration. + * + * Key advantage: Real LiDAR depth values instead of estimated depth. + */ + +import { useEffect, useRef, useState, useCallback } from 'react' +import type { GestureState, HandLandmarks, PinchRay } from './useHandGestures' +import type { NormalizedLandmarkList } from '@mediapipe/hands' +import { OneEuroFilter, OneEuroFilter3D, ONE_EURO_PRESETS } from '../utils/OneEuroFilter' + +// 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 = { + // 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 { + 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 + phonePort?: number + webPort?: number + phoneConnected?: boolean + ips?: string[] + lastHandFrameAt?: number | null +} + +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 +} + +// Legacy lerp and smoothing for two-hand gestures (zoom/rotate) +// Two-hand interactions are less sensitive to jitter, so simple lerp is fine +const TWO_HAND_SMOOTHING = 0.3 + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +// 1€ Filter instances for smooth, responsive tracking +// These are created per-hand and persist across frames +interface HandFilters { + landmarks: OneEuroFilter3D[] // 21 landmarks + pinch: OneEuroFilter + grab: OneEuroFilter + pointerDirection: OneEuroFilter3D +} + +function createHandFilters(): HandFilters { + return { + landmarks: Array.from({ length: 21 }, () => new OneEuroFilter3D(ONE_EURO_PRESETS.landmark)), + pinch: new OneEuroFilter(ONE_EURO_PRESETS.gesture), + grab: new OneEuroFilter(ONE_EURO_PRESETS.gesture), + pointerDirection: new OneEuroFilter3D(ONE_EURO_PRESETS.pointer), + } +} + +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) +} + +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'] + const indexTip = landmarks['VNHLKITIP'] + 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)) +} + +// Closed fist strength from fingertip-to-wrist distances +function calculateGrabStrength(landmarks: Record): number { + const wrist = landmarks['VNHLKWRI'] + if (!wrist) return 0 + + const tips = ['VNHLKTTIP', 'VNHLKITIP', 'VNHLKMTIP', 'VNHLKRTIP', 'VNHLKPTIP'] + .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 normalized depth) +function calculatePinchRay(landmarks: Record, hasLiDAR: boolean): PinchRay { + 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, + z: (thumbZ + indexZ) / 2, + } + + // Direction: from wrist through pinch point + const rawDir = { + x: origin.x - wrist.x, + y: origin.y - wrist.y, + 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) + 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 + + // Mirror X to match the viewer's "self view" coordinate system. + // This keeps the iPhone (back-camera) feed consistent with the webcam UI. + return { + origin: { ...origin, x: 1 - origin.x }, + direction: { ...direction, x: -direction.x }, + isValid, + strength, + } +} + +// Normalize LiDAR depth (meters) to MediaPipe-like relative depth +// 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 + + // 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: 1 - 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 = [] + + // 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: 1 - lm.x, + y: lm.y, + // Normalize LiDAR depth to MediaPipe-like values + z: normalizeLiDARDepth(lm.z, hasLiDAR), + 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, + pinchPoint: null, + leftPinchRay: null, + rightPinchRay: null, + activePinchRay: null, + zoomDelta: 0, + rotateDelta: 0, + panDelta: { x: 0, y: 0 }, +} + +export function useIPhoneHandTracking(options: UseIPhoneHandTrackingOptions = {}) { + const { + // 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 + + const [gestureState, setGestureState] = useState(DEFAULT_STATE) + 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) + const frameCountRef = useRef(0) + const lastFpsTimeRef = useRef(Date.now()) + const reconnectTimeoutRef = useRef() + const messageCountRef = useRef(0) + const hasLoggedLandmarksRef = useRef(false) + + // 1€ Filters for smooth tracking (one set per hand) + const leftFiltersRef = useRef(createHandFilters()) + const rightFiltersRef = useRef(createHandFilters()) + + // 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) { + // 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]) + } + + // Get the appropriate filter set for this hand + const filters = hand.handedness === 'left' ? leftFiltersRef.current : rightFiltersRef.current + + // Convert raw landmarks + const rawLandmarks = convertToMediaPipeLandmarks(hand.landmarks, hand.hasLiDARDepth) + const rawWorldLandmarks = convertToWorldLandmarksMeters(hand.landmarks, hand.hasLiDARDepth) + + // Apply 1€ filter to each landmark for smooth, responsive tracking + const landmarks: NormalizedLandmarkList = rawLandmarks.map((lm, idx) => { + const filtered = filters.landmarks[idx].filter( + { x: lm.x, y: lm.y, z: lm.z ?? 0 }, + now + ) + return { ...filtered, visibility: lm.visibility } + }) + + const worldLandmarks: NormalizedLandmarkList = rawWorldLandmarks.map((lm, idx) => { + // World landmarks use same filter as screen landmarks for consistency + const filtered = filters.landmarks[idx].filter( + { x: lm.x, y: lm.y, z: lm.z ?? 0 }, + now + ) + return { ...filtered, visibility: lm.visibility } + }) + + const handData: HandLandmarks = { + landmarks, + worldLandmarks, + 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 - TWO_HAND_SMOOTHING) + + // Rotation + const rawRotation = Math.atan2(rightWrist.y - leftWrist.y, rightWrist.x - leftWrist.x) + newState.twoHandRotation = lerp(prev.twoHandRotation, rawRotation, 1 - TWO_HAND_SMOOTHING) + + // Center + newState.twoHandCenter = { + x: lerp(prev.twoHandCenter.x, (leftWrist.x + rightWrist.x) / 2, 1 - TWO_HAND_SMOOTHING), + y: lerp(prev.twoHandCenter.y, (leftWrist.y + rightWrist.y) / 2, 1 - TWO_HAND_SMOOTHING), + } + + // Deltas + newState.zoomDelta = (newState.twoHandDistance - prev.twoHandDistance) * 5 + 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/grab strength (filtered with 1€ for responsive but stable values) + const primaryHand = newState.rightHand || newState.leftHand + if (primaryHand) { + const isRight = primaryHand.handedness === 'Right' + const filters = isRight ? rightFiltersRef.current : leftFiltersRef.current + + 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 rawPinch = calculatePinchStrength(reconstructed) + const rawGrab = calculateGrabStrength(reconstructed) + + // Apply 1€ filter for smooth but responsive gesture detection + newState.pinchStrength = filters.pinch.filter(rawPinch, now) + newState.grabStrength = filters.grab.filter(rawGrab, now) + + // Calculate pinch point (midpoint between thumb tip and index tip) + // This is the "pick the berry" selection point + const thumbTip = primaryHand.landmarks[4] // THUMB_TIP + const indexTip = primaryHand.landmarks[8] // INDEX_TIP + if (thumbTip && indexTip) { + newState.pinchPoint = { + x: (thumbTip.x + indexTip.x) / 2, + y: (thumbTip.y + indexTip.y) / 2, + } + } + } + + 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 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, event.data) + } + } + + ws.onclose = () => { + console.log('📱 Disconnected from iPhone') + setIsConnected(false) + setGestureState(DEFAULT_STATE) + setPhoneConnected(false) + + // Reset filters when disconnected (important for clean re-acquisition) + leftFiltersRef.current = createHandFilters() + rightFiltersRef.current = createHandFilters() + + // 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, + phoneConnected, + bridgeIps, + phonePort, + isEnabled: enabled && isConnected, + } +} + +export default useIPhoneHandTracking diff --git a/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts new file mode 100644 index 0000000..c6fbd40 --- /dev/null +++ b/packages/graph-viewer/src/hooks/useKeyboardNavigation.ts @@ -0,0 +1,326 @@ +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 + onSaveBookmark?: () => void + onQuickNavigate?: (index: number) => void + onStartPathfinding?: () => void + onCancelPathfinding?: () => void + isPathSelecting?: boolean + 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, + onSaveBookmark, + onQuickNavigate, + onStartPathfinding, + onCancelPathfinding, + isPathSelecting, + 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 + } + + // 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: { + 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 / 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', + 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 / 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(' Cmd+B: Save bookmark') + console.log(' 1-9: Quick navigate to bookmark') + }, + }, + } + + const shortcut = shortcuts[event.key] + if (shortcut) { + shortcut.action() + } + }, + [enabled, findNodeInDirection, navigateSequential, onNodeSelect, onReheat, onResetView, onToggleSettings, onToggleLabels, onSaveBookmark, onQuickNavigate, onStartPathfinding, onCancelPathfinding, isPathSelecting] + ) + + // 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 / 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: '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 new file mode 100644 index 0000000..3c24673 --- /dev/null +++ b/packages/graph-viewer/src/index.css @@ -0,0 +1,74 @@ +@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); +} + +/* 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/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() + } +} 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 +} 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 } diff --git a/packages/graph-viewer/src/lib/types.ts b/packages/graph-viewer/src/lib/types.ts new file mode 100644 index 0000000..f9fdbe4 --- /dev/null +++ b/packages/graph-viewer/src/lib/types.ts @@ -0,0 +1,205 @@ +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 +} + +// 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 + useUMAP: boolean // Use UMAP embedding projection for positioning +} + +// 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: -50, + linkStrength: 0.85, + linkDistance: 35, + 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: 'none', + showBoundaries: true, + clusterStrength: 0.3, + useUMAP: false, +} + +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, +} 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/utils/OneEuroFilter.ts b/packages/graph-viewer/src/utils/OneEuroFilter.ts new file mode 100644 index 0000000..580034b --- /dev/null +++ b/packages/graph-viewer/src/utils/OneEuroFilter.ts @@ -0,0 +1,273 @@ +/** + * One Euro Filter - Adaptive Low-Pass Filter for Noisy Input + * + * The 1€ filter is the industry standard for VR/AR hand tracking smoothing. + * It adapts its cutoff frequency based on movement speed: + * - At low speeds: More aggressive smoothing (reduces jitter) + * - At high speeds: Less smoothing (reduces lag) + * + * Reference: Casiez, Roussel, Vogel. "1€ Filter: A Simple Speed-based + * Low-pass Filter for Noisy Input in Interactive Systems" (CHI 2012) + * + * Used by: Meta Quest, Microsoft HoloLens, Apple Vision Pro + */ + +// Low-pass filter with exponential smoothing +class LowPassFilter { + private y: number | null = null + private a: number = 0 + + constructor(alpha: number = 1.0) { + this.a = alpha + } + + setAlpha(alpha: number): void { + this.a = Math.max(0, Math.min(1, alpha)) + } + + filter(value: number): number { + if (this.y === null) { + this.y = value + } else { + this.y = this.a * value + (1 - this.a) * this.y + } + return this.y + } + + hasLastValue(): boolean { + return this.y !== null + } + + getLastValue(): number { + return this.y ?? 0 + } + + reset(): void { + this.y = null + } +} + +export interface OneEuroFilterConfig { + /** + * Minimum cutoff frequency (Hz). Controls jitter at low speeds. + * Lower = more smoothing when still/slow. Default: 1.0 + * Recommended range: 0.5 - 3.0 + */ + minCutoff?: number + + /** + * Speed coefficient (beta). Controls lag at high speeds. + * Higher = less lag when moving fast. Default: 0.007 + * Recommended range: 0.001 - 0.1 + */ + beta?: number + + /** + * Derivative cutoff frequency (Hz). Smooths the velocity estimation. + * Higher = more responsive to speed changes. Default: 1.0 + * Recommended range: 0.5 - 2.0 + */ + dCutoff?: number + + /** + * Expected sample rate in Hz. Used for timestamping. + * Default: 60 (typical camera frame rate) + */ + freq?: number +} + +// Default configs optimized for different use cases +export const ONE_EURO_PRESETS = { + // For landmark positions - prioritize stability + landmark: { minCutoff: 0.8, beta: 0.005, dCutoff: 1.0 }, + + // For pointer/ray direction - balance stability and responsiveness + pointer: { minCutoff: 1.2, beta: 0.008, dCutoff: 1.0 }, + + // For gesture strength values (pinch, grab) - quick response + gesture: { minCutoff: 1.5, beta: 0.01, dCutoff: 1.2 }, + + // High precision for selection - very stable + precision: { minCutoff: 0.5, beta: 0.003, dCutoff: 0.8 }, + + // Fast motion tracking - minimize lag + fastMotion: { minCutoff: 2.0, beta: 0.02, dCutoff: 1.5 }, +} as const + +export class OneEuroFilter { + private freq: number + private minCutoff: number + private beta: number + private dCutoff: number + + private x: LowPassFilter + private dx: LowPassFilter + private lastTime: number | null = null + + constructor(config: OneEuroFilterConfig = {}) { + this.freq = config.freq ?? 60 + this.minCutoff = config.minCutoff ?? 1.0 + this.beta = config.beta ?? 0.007 + this.dCutoff = config.dCutoff ?? 1.0 + + this.x = new LowPassFilter(this.alpha(this.minCutoff)) + this.dx = new LowPassFilter(this.alpha(this.dCutoff)) + } + + private alpha(cutoff: number): number { + const te = 1.0 / this.freq + const tau = 1.0 / (2 * Math.PI * cutoff) + return 1.0 / (1.0 + tau / te) + } + + /** + * Filter a value with automatic timestamping + * @param value The noisy input value + * @param timestamp Optional timestamp in milliseconds. If not provided, uses Date.now() + * @returns Filtered (smoothed) value + */ + filter(value: number, timestamp?: number): number { + const t = timestamp ?? Date.now() + + // Calculate time delta and update frequency + if (this.lastTime !== null) { + const dt = (t - this.lastTime) / 1000 // Convert ms to seconds + if (dt > 0 && dt < 1) { + // Clamp to reasonable range + this.freq = 1.0 / dt + } + } + this.lastTime = t + + // Estimate derivative (velocity) + const prevX = this.x.hasLastValue() ? this.x.getLastValue() : value + const dx = this.freq * (value - prevX) + + // Filter the derivative + this.dx.setAlpha(this.alpha(this.dCutoff)) + const edx = this.dx.filter(dx) + + // Adaptive cutoff based on velocity magnitude + // Key insight: fc = minCutoff + beta * |velocity| + const cutoff = this.minCutoff + this.beta * Math.abs(edx) + + // Filter the value with adaptive cutoff + this.x.setAlpha(this.alpha(cutoff)) + return this.x.filter(value) + } + + /** + * Reset the filter state. Call when tracking is lost/reacquired. + */ + reset(): void { + this.x.reset() + this.dx.reset() + this.lastTime = null + } + + /** + * Update configuration parameters + */ + setConfig(config: Partial): void { + if (config.minCutoff !== undefined) this.minCutoff = config.minCutoff + if (config.beta !== undefined) this.beta = config.beta + if (config.dCutoff !== undefined) this.dCutoff = config.dCutoff + if (config.freq !== undefined) this.freq = config.freq + } +} + +/** + * Filter for 2D points (x, y) + */ +export class OneEuroFilter2D { + private xFilter: OneEuroFilter + private yFilter: OneEuroFilter + + constructor(config: OneEuroFilterConfig = {}) { + this.xFilter = new OneEuroFilter(config) + this.yFilter = new OneEuroFilter(config) + } + + filter(point: { x: number; y: number }, timestamp?: number): { x: number; y: number } { + return { + x: this.xFilter.filter(point.x, timestamp), + y: this.yFilter.filter(point.y, timestamp), + } + } + + reset(): void { + this.xFilter.reset() + this.yFilter.reset() + } + + setConfig(config: Partial): void { + this.xFilter.setConfig(config) + this.yFilter.setConfig(config) + } +} + +/** + * Filter for 3D points (x, y, z) + */ +export class OneEuroFilter3D { + private xFilter: OneEuroFilter + private yFilter: OneEuroFilter + private zFilter: OneEuroFilter + + constructor(config: OneEuroFilterConfig = {}) { + this.xFilter = new OneEuroFilter(config) + this.yFilter = new OneEuroFilter(config) + this.zFilter = new OneEuroFilter(config) + } + + filter(point: { x: number; y: number; z: number }, timestamp?: number): { x: number; y: number; z: number } { + return { + x: this.xFilter.filter(point.x, timestamp), + y: this.yFilter.filter(point.y, timestamp), + z: this.zFilter.filter(point.z, timestamp), + } + } + + reset(): void { + this.xFilter.reset() + this.yFilter.reset() + this.zFilter.reset() + } + + setConfig(config: Partial): void { + this.xFilter.setConfig(config) + this.yFilter.setConfig(config) + this.zFilter.setConfig(config) + } +} + +/** + * Filter for all 21 hand landmarks at once + */ +export class HandLandmarkFilter { + private filters: OneEuroFilter3D[] + + constructor(config: OneEuroFilterConfig = ONE_EURO_PRESETS.landmark) { + this.filters = Array.from({ length: 21 }, () => new OneEuroFilter3D(config)) + } + + filter( + landmarks: Array<{ x: number; y: number; z: number; visibility?: number }>, + timestamp?: number + ): Array<{ x: number; y: number; z: number; visibility?: number }> { + return landmarks.map((lm, i) => ({ + ...this.filters[i].filter(lm, timestamp), + visibility: lm.visibility, + })) + } + + reset(): void { + this.filters.forEach((f) => f.reset()) + } + + setConfig(config: Partial): void { + this.filters.forEach((f) => f.setConfig(config)) + } +} + +export default OneEuroFilter 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..5e76e4a --- /dev/null +++ b/packages/graph-viewer/vite.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + // 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: { + port: 5173, + proxy: { + // Proxy to Railway backend (or override via VITE_API_TARGET env var) + '/graph': { + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', + changeOrigin: true, + secure: true, + }, + '/recall': { + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', + changeOrigin: true, + secure: true, + }, + '/memory': { + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', + changeOrigin: true, + secure: true, + }, + '/health': { + target: process.env.VITE_API_TARGET || 'https://automem.up.railway.app', + changeOrigin: true, + secure: true, + }, + }, + }, +}) diff --git a/requirements.txt b/requirements.txt index 73637e1..529e59f 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 @@ -9,4 +10,5 @@ spacy==3.8.7 requests==2.31.0 fastembed==0.4.2 onnxruntime<1.20 # Pin to avoid issues with fastembed 0.4.2 +umap-learn>=0.5.5 # Dimensionality reduction for embedding visualization en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0.tar.gz