morozovdd commited on
Commit
23680f2
·
1 Parent(s): b626d26

feat: add HyperView app for space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +65 -0
  2. .github/workflows/devin-review.yml +23 -0
  3. .github/workflows/require_frontend_export.yml +53 -0
  4. .gitignore +74 -0
  5. Dockerfile +123 -0
  6. LICENSE +21 -0
  7. README.md +149 -6
  8. app_hf.py +49 -0
  9. docs/architecture.md +54 -0
  10. docs/colab.md +37 -0
  11. docs/datasets.md +96 -0
  12. docs/index.html +465 -0
  13. frontend/components.json +22 -0
  14. frontend/eslint.config.mjs +22 -0
  15. frontend/next-env.d.ts +6 -0
  16. frontend/next.config.ts +24 -0
  17. frontend/package-lock.json +0 -0
  18. frontend/package.json +51 -0
  19. frontend/postcss.config.mjs +9 -0
  20. frontend/src/app/globals.css +224 -0
  21. frontend/src/app/layout.tsx +26 -0
  22. frontend/src/app/page.tsx +277 -0
  23. frontend/src/components/DockviewWorkspace.tsx +765 -0
  24. frontend/src/components/ExplorerPanel.tsx +181 -0
  25. frontend/src/components/Header.tsx +244 -0
  26. frontend/src/components/ImageGrid.tsx +338 -0
  27. frontend/src/components/Panel.tsx +43 -0
  28. frontend/src/components/PanelHeader.tsx +47 -0
  29. frontend/src/components/PlaceholderPanel.tsx +39 -0
  30. frontend/src/components/ScatterPanel.tsx +174 -0
  31. frontend/src/components/icons.tsx +73 -0
  32. frontend/src/components/index.ts +9 -0
  33. frontend/src/components/ui/button.tsx +57 -0
  34. frontend/src/components/ui/collapsible.tsx +11 -0
  35. frontend/src/components/ui/command.tsx +153 -0
  36. frontend/src/components/ui/dialog.tsx +122 -0
  37. frontend/src/components/ui/dropdown-menu.tsx +201 -0
  38. frontend/src/components/ui/popover.tsx +33 -0
  39. frontend/src/components/ui/radio-group.tsx +44 -0
  40. frontend/src/components/ui/scroll-area.tsx +49 -0
  41. frontend/src/components/ui/separator.tsx +31 -0
  42. frontend/src/components/ui/toggle-group.tsx +61 -0
  43. frontend/src/components/ui/toggle.tsx +45 -0
  44. frontend/src/components/ui/tooltip.tsx +32 -0
  45. frontend/src/components/useHyperScatter.ts +614 -0
  46. frontend/src/components/useLabelLegend.ts +85 -0
  47. frontend/src/lib/api.ts +103 -0
  48. frontend/src/lib/labelColors.ts +153 -0
  49. frontend/src/lib/labelLegend.ts +248 -0
  50. frontend/src/lib/layouts.ts +17 -0
.dockerignore ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+
5
+ # Python
6
+ __pycache__
7
+ *.py[cod]
8
+ *$py.class
9
+ *.so
10
+ .Python
11
+ .venv
12
+ venv
13
+ env
14
+ .env
15
+ *.egg-info
16
+ dist
17
+ build
18
+ .eggs
19
+
20
+ # Node
21
+ node_modules
22
+ .npm
23
+ .pnpm-store
24
+
25
+ # IDE
26
+ .idea
27
+ .vscode
28
+ *.swp
29
+ *.swo
30
+
31
+ # Testing
32
+ .pytest_cache
33
+ .coverage
34
+ htmlcov
35
+ .tox
36
+
37
+ # Documentation (not needed in image)
38
+ docs
39
+
40
+ # Development files
41
+ *.log
42
+ .DS_Store
43
+ Thumbs.db
44
+
45
+ # Notebooks (not needed for deployment)
46
+ notebooks
47
+ *.ipynb
48
+
49
+ # POC code
50
+ poc
51
+
52
+ # Local data
53
+ *.lancedb
54
+ data/
55
+
56
+ # Frontend build output (we build fresh)
57
+ frontend/out
58
+ frontend/.next
59
+ frontend/node_modules
60
+
61
+ # hyper-scatter (built separately if present)
62
+ hyper-scatter
63
+
64
+ # Assets (README images)
65
+ assets
.github/workflows/devin-review.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Devin Review
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, reopened]
6
+
7
+ jobs:
8
+ devin-review:
9
+ runs-on: ubuntu-latest
10
+
11
+ steps:
12
+ - name: Checkout repository
13
+ uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0
16
+
17
+ - name: Setup Node.js
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: '20'
21
+
22
+ - name: Run Devin Review
23
+ run: npx devin-review ${{ github.event.pull_request.html_url }}
.github/workflows/require_frontend_export.yml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Require Frontend Export
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, reopened]
6
+ paths:
7
+ - "frontend/**"
8
+ - "scripts/export_frontend.sh"
9
+ - "src/hyperview/server/static/**"
10
+
11
+ jobs:
12
+ require-frontend-export:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+ ref: ${{ github.event.pull_request.head.sha }}
21
+
22
+ - name: Verify static export updated when frontend changes
23
+ run: |
24
+ set -euo pipefail
25
+
26
+ base_sha="${{ github.event.pull_request.base.sha }}"
27
+ head_sha="${{ github.event.pull_request.head.sha }}"
28
+
29
+ changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
30
+
31
+ echo "Changed files:"
32
+ echo "$changed_files"
33
+
34
+ frontend_changed="false"
35
+ static_changed="false"
36
+
37
+ if echo "$changed_files" | grep -qE '^(frontend/|scripts/export_frontend\.sh$)'; then
38
+ frontend_changed="true"
39
+ fi
40
+
41
+ if echo "$changed_files" | grep -q '^src/hyperview/server/static/'; then
42
+ static_changed="true"
43
+ fi
44
+
45
+ if [[ "$frontend_changed" == "true" && "$static_changed" != "true" ]]; then
46
+ echo ""
47
+ echo "ERROR: frontend/ changed but src/hyperview/server/static/ was not updated."
48
+ echo "Run: bash scripts/export_frontend.sh"
49
+ echo "Then commit the updated src/hyperview/server/static/ output."
50
+ exit 1
51
+ fi
52
+
53
+ echo "OK: export requirements satisfied."
.gitignore ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ env/
8
+ venv/
9
+ .venv/
10
+ /uv.lock
11
+ *.egg-info/
12
+ .pytest_cache/
13
+ .coverage
14
+ htmlcov/
15
+
16
+ # Jupyter Notebooks
17
+ .ipynb_checkpoints
18
+
19
+ # macOS
20
+ .DS_Store
21
+
22
+ # VS Code
23
+ .vscode/
24
+
25
+ # Generated assets
26
+ assets/demo_animation_frames/
27
+ *.gif
28
+
29
+ # Frontend
30
+ frontend/node_modules/
31
+ frontend/.next/
32
+ frontend/out/
33
+
34
+ # Bundled frontend in Python package (built with scripts/export_frontend.sh)
35
+ # Not ignored - needed for pip install from git / sdist
36
+ # src/hyperview/server/static/
37
+
38
+ # Python package build
39
+ dist/
40
+ build/
41
+ *.egg-info/
42
+
43
+ # Data cache
44
+ *.hf/
45
+ .cache/
46
+
47
+ # external repo (https://github.com/Hyper3Labs/hyper-scatter)
48
+ hyper-scatter/
49
+
50
+ # nohup
51
+ nohup.out
52
+ frontend/nohup.out
53
+
54
+ # Local logs / tool artifacts
55
+ .hyperview-*.log
56
+ .hyperview-*.pid
57
+ .playwright-mcp/
58
+ frontend/tsconfig.tsbuildinfo
59
+
60
+ # Hyperbolic model zoo (kept as a separate repo)
61
+ hyper_model_zoo/
62
+ hyper_models/
63
+ scripts_ignored/
64
+
65
+ # AI Context (Agent files)
66
+ .claude/
67
+ context/
68
+ CLAUDE.md
69
+ TASKS.md
70
+ TESTS.md
71
+ AGENTS.md
72
+ **/AGENTS.md
73
+ .github/agents/
74
+ .specstory/
Dockerfile ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # HyperView - Hugging Face Spaces Dockerfile
3
+ # =============================================================================
4
+ # Multi-stage build for deploying HyperView to HuggingFace Spaces.
5
+ #
6
+ # Features:
7
+ # - CLIP embeddings (Euclidean) via embed-anything
8
+ # - HyCoCLIP embeddings (Hyperbolic) via hyper-models ONNX
9
+ # - Pre-computed demo dataset (300 CIFAR-10 samples)
10
+ # - Torch-free runtime for minimal image size
11
+ #
12
+ # Deploy: https://huggingface.co/spaces/Hyper3Labs/HyperView
13
+ # =============================================================================
14
+
15
+ # -----------------------------------------------------------------------------
16
+ # Stage 1: Build Frontend (Next.js static export)
17
+ # -----------------------------------------------------------------------------
18
+ FROM node:20-slim AS frontend-builder
19
+
20
+ WORKDIR /app/frontend
21
+
22
+ # Install dependencies first (better caching)
23
+ COPY frontend/package.json frontend/package-lock.json ./
24
+ RUN npm ci --prefer-offline
25
+
26
+ # Build hyper-scatter (installed from source tarball, dist-lib not prebuilt)
27
+ RUN cd node_modules/hyper-scatter \
28
+ && npm install \
29
+ && npm run build:lib
30
+
31
+ # Copy frontend source and build
32
+ COPY frontend/ ./
33
+ RUN npm run build
34
+
35
+ # Verify output exists
36
+ RUN ls -la out/ && echo "Frontend build complete"
37
+
38
+ # -----------------------------------------------------------------------------
39
+ # Stage 2: Python Runtime
40
+ # -----------------------------------------------------------------------------
41
+ FROM python:3.11-slim AS runtime
42
+
43
+ # Install system dependencies
44
+ RUN apt-get update && apt-get install -y --no-install-recommends \
45
+ build-essential \
46
+ curl \
47
+ git \
48
+ libssl-dev \
49
+ pkg-config \
50
+ && rm -rf /var/lib/apt/lists/*
51
+
52
+ # HuggingFace Spaces requirement: create user with UID 1000
53
+ RUN useradd -m -u 1000 user
54
+
55
+ # Switch to user
56
+ USER user
57
+
58
+ # Set environment variables
59
+ ENV HOME=/home/user \
60
+ PATH=/home/user/.local/bin:$PATH \
61
+ HF_HOME=/home/user/.cache/huggingface \
62
+ PYTHONUNBUFFERED=1 \
63
+ PIP_NO_CACHE_DIR=1
64
+
65
+ WORKDIR $HOME/app
66
+
67
+ # Upgrade pip
68
+ RUN pip install --upgrade pip
69
+
70
+ # Copy Python package files
71
+ COPY --chown=user pyproject.toml README.md LICENSE ./
72
+ COPY --chown=user src/ ./src/
73
+ COPY --chown=user scripts/ ./scripts/
74
+
75
+ # Install Python package (without ML extras - we use ONNX)
76
+ RUN pip install -e .
77
+
78
+ # Copy built frontend to static directory
79
+ COPY --from=frontend-builder --chown=user /app/frontend/out ./src/hyperview/server/static/
80
+
81
+ # Verify frontend is in place
82
+ RUN ls -la src/hyperview/server/static/ && echo "Frontend copied successfully"
83
+
84
+ # -----------------------------------------------------------------------------
85
+ # Stage 3: Pre-compute Demo Dataset
86
+ # -----------------------------------------------------------------------------
87
+ # Create output directories
88
+ RUN mkdir -p $HOME/app/demo_data/datasets $HOME/app/demo_data/media
89
+
90
+ # Set environment for precomputation
91
+ ENV HYPERVIEW_DATASETS_DIR=/home/user/app/demo_data/datasets \
92
+ HYPERVIEW_MEDIA_DIR=/home/user/app/demo_data/media \
93
+ DEMO_SAMPLES=300
94
+
95
+ # Pre-download HuggingFace models and compute embeddings
96
+ # This runs during build to ensure fast startup
97
+ RUN python scripts/precompute_hf_demo.py
98
+
99
+ # Verify dataset was created
100
+ RUN ls -la demo_data/ && echo "Demo dataset pre-computed successfully"
101
+
102
+ # -----------------------------------------------------------------------------
103
+ # Final Configuration
104
+ # -----------------------------------------------------------------------------
105
+ # Copy entrypoint
106
+ COPY --chown=user app_hf.py ./
107
+
108
+ # Set runtime environment
109
+ ENV HOST=0.0.0.0 \
110
+ PORT=7860 \
111
+ DEMO_DATASET=cifar10_hf_demo \
112
+ HYPERVIEW_DATASETS_DIR=/home/user/app/demo_data/datasets \
113
+ HYPERVIEW_MEDIA_DIR=/home/user/app/demo_data/media
114
+
115
+ # Expose port (HuggingFace Spaces default)
116
+ EXPOSE 7860
117
+
118
+ # Health check
119
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
120
+ CMD curl -f http://localhost:7860/__hyperview__/health || exit 1
121
+
122
+ # Start server
123
+ CMD ["python", "app_hf.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Matin Mahmood
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,12 +1,155 @@
1
  ---
2
  title: HyperView
3
- emoji: 😻
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: docker
 
7
  pinned: false
8
- license: apache-2.0
9
- short_description: 'dataset curation + embedding visualization '
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: HyperView
3
+ emoji: 🔮
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ tags:
10
+ - data-visualization
11
+ - embeddings
12
+ - hyperbolic
13
+ - poincare
14
+ - clip
15
+ - dataset-curation
16
+ - computer-vision
17
+ - umap
18
+ short_description: Dataset visualization with Euclidean & hyperbolic embeddings
19
+ models:
20
+ - openai/clip-vit-base-patch32
21
+ - mnm-matin/hyperbolic-clip
22
+ datasets:
23
+ - uoft-cs/cifar10
24
  ---
25
 
26
+ # HyperView
27
+
28
+ > **Open-source dataset curation + embedding visualization (Euclidean + Poincaré disk)**
29
+
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Hyper3Labs/HyperView) [![Hugging Face Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Spaces-blue)](https://huggingface.co/spaces/Hyper3Labs/HyperView)
31
+
32
+ <p align="center">
33
+ <a href="https://youtu.be/XLaa8FHSQtc" target="_blank">
34
+ <img src="https://raw.githubusercontent.com/Hyper3Labs/HyperView/main/assets/screenshot.png" alt="HyperView Screenshot" width="100%">
35
+ </a>
36
+ <br>
37
+ <a href="https://youtu.be/XLaa8FHSQtc" target="_blank">Watch the Demo Video</a>
38
+ </p>
39
+
40
+ ---
41
+
42
+ ## Try it Online
43
+
44
+ **[Launch HyperView on Hugging Face Spaces](https://huggingface.co/spaces/Hyper3Labs/HyperView)** - no installation required!
45
+
46
+ The demo showcases:
47
+ - 300 CIFAR-10 images with pre-computed embeddings
48
+ - CLIP embeddings visualized in Euclidean space (UMAP)
49
+ - HyCoCLIP embeddings visualized on the Poincaré disk
50
+
51
+ ---
52
+
53
+ ## Features
54
+
55
+ - **Dual-Panel UI**: Image grid + scatter plot with bidirectional selection
56
+ - **Euclidean/Poincaré Toggle**: Switch between standard 2D UMAP and Poincaré disk visualization
57
+ - **HuggingFace Integration**: Load datasets directly from HuggingFace Hub
58
+ - **Fast Embeddings**: Uses EmbedAnything for CLIP-based image embeddings
59
+
60
+ ## Quick Start
61
+
62
+ **Docs:** [docs/datasets.md](docs/datasets.md) · [docs/colab.md](docs/colab.md) · [CONTRIBUTING.md](CONTRIBUTING.md) · [TESTS.md](TESTS.md)
63
+
64
+ ### Installation
65
+
66
+ ```bash
67
+ git clone https://github.com/Hyper3Labs/HyperView.git
68
+ cd HyperView
69
+
70
+ # Install with uv
71
+ uv venv .venv
72
+ source .venv/bin/activate
73
+ uv pip install -e ".[dev]"
74
+ ```
75
+
76
+ ### Run the Demo
77
+
78
+ ```bash
79
+ hyperview demo --samples 500
80
+ ```
81
+
82
+ This will:
83
+ 1. Load 500 samples from CIFAR-100
84
+ 2. Compute CLIP embeddings
85
+ 3. Generate Euclidean and Poincaré visualizations
86
+ 4. Start the server at **http://127.0.0.1:6262**
87
+
88
+ ### Python API
89
+
90
+ ```python
91
+ import hyperview as hv
92
+
93
+ # Create dataset
94
+ dataset = hv.Dataset("my_dataset")
95
+
96
+ # Load from HuggingFace
97
+ dataset.add_from_huggingface(
98
+ "uoft-cs/cifar100",
99
+ split="train",
100
+ max_samples=1000
101
+ )
102
+
103
+ # Or load from local directory
104
+ # dataset.add_images_dir("/path/to/images", label_from_folder=True)
105
+
106
+ # Compute embeddings and visualization
107
+ dataset.compute_embeddings()
108
+ dataset.compute_visualization()
109
+
110
+ # Launch the UI
111
+ hv.launch(dataset) # Opens http://127.0.0.1:6262
112
+ ```
113
+
114
+ ### Google Colab
115
+
116
+ See [docs/colab.md](docs/colab.md) for a fast Colab smoke test and notebook-friendly launch behavior.
117
+
118
+ ### Save and Load Datasets
119
+
120
+ ```python
121
+ # Save dataset with embeddings
122
+ dataset.save("my_dataset.json")
123
+
124
+ # Load later
125
+ dataset = hv.Dataset.load("my_dataset.json")
126
+ hv.launch(dataset)
127
+ ```
128
+
129
+ ## Why Hyperbolic?
130
+
131
+ Traditional Euclidean embeddings struggle with hierarchical data. In Euclidean space, volume grows polynomially ($r^d$), causing **Representation Collapse** where minority classes get crushed together.
132
+
133
+ **Hyperbolic space** (Poincaré disk) has exponential volume growth ($e^r$), naturally preserving hierarchical structure and keeping rare classes distinct.
134
+
135
+ <p align="center">
136
+ <img src="https://raw.githubusercontent.com/Hyper3Labs/HyperView/main/assets/hyperview_infographic.png" alt="Euclidean vs Hyperbolic" width="100%">
137
+ </p>
138
+
139
+ ## Contributing
140
+
141
+ Development setup, frontend hot-reload, and backend API notes live in [CONTRIBUTING.md](CONTRIBUTING.md).
142
+
143
+ ## Related projects
144
+
145
+ - **hyper-scatter**: High-performance WebGL scatterplot engine (Euclidean + Poincaré) used by the frontend: https://github.com/Hyper3Labs/hyper-scatter
146
+ - **hyper-models**: Non-Euclidean model zoo + ONNX exports (e.g. for hyperbolic VLM experiments): https://github.com/Hyper3Labs/hyper-models
147
+
148
+ ## References
149
+
150
+ - [Poincaré Embeddings for Learning Hierarchical Representations](https://arxiv.org/abs/1705.08039) (Nickel & Kiela, 2017)
151
+ - [Hyperbolic Neural Networks](https://arxiv.org/abs/1805.09112) (Ganea et al., 2018)
152
+
153
+ ## License
154
+
155
+ MIT License - see [LICENSE](LICENSE) for details.
app_hf.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Hugging Face Spaces entrypoint for HyperView.
3
+
4
+ This script serves a pre-computed demo dataset stored in LanceDB.
5
+ The dataset is computed at Docker build time by scripts/precompute_hf_demo.py.
6
+
7
+ For HuggingFace Spaces deployment under Hyper3Labs/HyperView.
8
+ """
9
+
10
+ import os
11
+
12
+ # Configuration from environment
13
+ HOST = os.environ.get("HOST", "0.0.0.0")
14
+ PORT = int(os.environ.get("PORT", 7860))
15
+ DATASET_NAME = os.environ.get("DEMO_DATASET", "cifar10_hf_demo")
16
+
17
+
18
+ def main() -> None:
19
+ """Load pre-computed dataset and launch server."""
20
+ import hyperview as hv
21
+
22
+ dataset = hv.Dataset(DATASET_NAME)
23
+
24
+ spaces = dataset.list_spaces()
25
+ layouts = dataset.list_layouts()
26
+
27
+ if not spaces or not layouts:
28
+ print("Pre-computed embeddings not found in storage.")
29
+ print("Falling back to computing on startup (this will be slow)...")
30
+
31
+ from scripts.precompute_hf_demo import create_demo_dataset
32
+
33
+ dataset = create_demo_dataset()
34
+ else:
35
+ print(f"Loaded dataset '{DATASET_NAME}' with pre-computed embeddings")
36
+
37
+ print(f"\nStarting HyperView server on {HOST}:{PORT}")
38
+ print("=" * 50)
39
+
40
+ hv.launch(
41
+ dataset,
42
+ host=HOST,
43
+ port=PORT,
44
+ open_browser=False,
45
+ )
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
docs/architecture.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HyperView System Architecture
2
+
3
+ ## The Integrated Pipeline Approach
4
+
5
+ HyperView is built as a three-stage pipeline that turns raw multimodal data into an interactive, fairness-aware view of a dataset. Each stage uses the tool best suited for the job:
6
+
7
+ * **Ingestion – Python (PyTorch/Geoopt):** Differentiable manifold operations and training of the Hyperbolic Adapter.
8
+ * **Storage & Retrieval – Rust (Qdrant):** Low-latency vector search with a custom Poincaré distance metric.
9
+ * **Visualization – Browser (WebGL/Deck.gl):** GPU-accelerated rendering of the Poincaré disk in the browser.
10
+
11
+ ## System Diagram
12
+
13
+ <p align="center">
14
+ <img src="../assets/hyperview_architecture.png" alt="HyperView System Architecture: The Integrated Pipeline Approach" width="100%">
15
+ </p>
16
+
17
+ ## Component Breakdown
18
+
19
+ ### 1. Ingestion: Hyperbolic Adapter (Python)
20
+ * **Role:** The bridge between flat (Euclidean) model embeddings and curved (hyperbolic) space.
21
+ * **Input:** Raw data (images/text) → standard model embeddings (e.g. CLIP/ResNet vectors).
22
+ * **Tech:** PyTorch, Geoopt.
23
+ * **Function:**
24
+ * Learns a small Hyperbolic Adapter using differentiable manifold operations.
25
+ * Uses the exponential map (`expmap0`) to project Euclidean vectors into the Poincaré ball.
26
+ * This is where minority and rare cases are expanded away from the crowded center so they remain distinguishable.
27
+
28
+ ### 2. Storage & Retrieval: Vector Engine (Rust / Qdrant)
29
+ * **Role:** The memory that stores and retrieves hyperbolic embeddings at scale.
30
+ * **Tech:** Qdrant (forked/extended in Rust).
31
+ * **Challenge:** Standard vector DBs only support dot, cosine, or Euclidean distance.
32
+ * **Solution:**
33
+ * Implement a custom `PoincareDistance` metric in Rust:
34
+ $$d(u, v) = \text{arccosh}\left(1 + 2 \frac{\lVert u - v\rVert^2}{(1 - \lVert u\rVert^2)(1 - \lVert v\rVert^2)}\right)$$
35
+ * Plug this metric into Qdrant’s HNSW index for fast nearest-neighbor search in hyperbolic space.
36
+ * This allows search results to respect the hierarchy in the data instead of collapsing the long tail.
37
+
38
+ ### 3. Visualization: Poincaré Disk Viewer (WebGL)
39
+ * **Role:** The lens that lets humans explore the structure of the dataset.
40
+ * **Tech:** React, Deck.gl, custom WebGL shaders.
41
+ * **Challenge:** Rendering 1M points in non-Euclidean geometry directly in the browser.
42
+ * **Solution:**
43
+ * Send raw hyperbolic coordinates to the GPU and render them directly onto the Poincaré disk using a custom shader (no CPU-side projection).
44
+ * Provide pan/zoom/selection so curators can inspect minority clusters, isolate rare subgroups at the boundary, and export curated subsets.
45
+
46
+ ## Data Flow: The Fairness Pipeline
47
+
48
+ 1. **Ingest:** User uploads a dataset (e.g. medical images, biodiversity data).
49
+ 2. **Embed:** Standard models (CLIP/ResNet/Whisper) produce Euclidean embeddings.
50
+ 3. **Expand:** The Hyperbolic Adapter projects them into the Poincaré ball; rare cases move towards the boundary instead of being crushed.
51
+ 4. **Index:** Qdrant stores these hyperbolic vectors with the custom Poincaré distance metric.
52
+ 5. **Query:** A user clicks on a minority example or defines a region of interest.
53
+ 6. **Search:** Qdrant returns semantic neighbors according to Poincaré distance, preserving the hierarchy between majority, minority, and rare subgroups.
54
+ 7. **Visualize & Curate:** The browser renders the Poincaré disk, highlighting clusters and long-tail regions so users can see gaps, remove duplicates, and build fairer training sets.
docs/colab.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Running HyperView in Google Colab
2
+
3
+ HyperView works natively in Google Colab. Because Colab runs on a remote VM, you cannot access `localhost` directly. HyperView handles this automatically.
4
+
5
+ ## Usage
6
+
7
+ ### 1. Install HyperView
8
+
9
+ ```bash
10
+ !pip install -q git+https://github.com/Hyper3Labs/HyperView.git
11
+ ```
12
+
13
+ ### 2. Launch the visualizer
14
+
15
+ When you run `hv.launch()`, a button labeled **“Open HyperView in a new tab”** will appear in the output. Click it to open the visualization.
16
+
17
+ ```python
18
+ # Minimal example
19
+ import numpy as np
20
+ import hyperview as hv
21
+ from hyperview.core.sample import SampleFromArray
22
+
23
+ dataset = hv.Dataset("colab_smoke", persist=False)
24
+
25
+ rng = np.random.default_rng(0)
26
+ for i in range(200):
27
+ img = (rng.random((64, 64, 3)) * 255).astype(np.uint8)
28
+ sample = SampleFromArray.from_array(id=f"s{i}", image_array=img, label="demo")
29
+ sample.embedding_2d = [float(rng.normal()), float(rng.normal())] # Dummy 2D points
30
+ dataset.add_sample(sample)
31
+
32
+ hv.launch(dataset)
33
+ ```
34
+
35
+ ## Technical Details
36
+
37
+ To support Colab, HyperView uses `google.colab.kernel.proxyPort` to expose the backend server. The UI is opened via a specially constructed "launcher" page that embeds the proxied application in a full-page iframe. This workaround ensures compatibility with modern browser security policies (like third-party cookie blocking) that often break direct proxy URLs.
docs/datasets.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Datasets
2
+
3
+ ## Creating a Dataset
4
+
5
+ ```python
6
+ import hyperview as hv
7
+
8
+ # Persistent dataset (default) - survives restarts
9
+ dataset = hv.Dataset("my_dataset")
10
+
11
+ # In-memory dataset - lost when process exits
12
+ dataset = hv.Dataset("my_dataset", persist=False)
13
+ ```
14
+
15
+ **Storage location:** `~/.hyperview/datasets/` (configurable via `HYPERVIEW_DATABASE_DIR`)
16
+
17
+ Internally, each dataset is stored as two Lance tables (directories) inside that folder:
18
+ - `hyperview_{dataset_name}.lance/` (samples)
19
+ - `hyperview_{dataset_name}_meta.lance/` (metadata like label colors)
20
+
21
+ ## Adding Samples
22
+
23
+ ### From HuggingFace
24
+ ```python
25
+ dataset.add_from_huggingface(
26
+ "uoft-cs/cifar100",
27
+ split="train",
28
+ image_key="img",
29
+ label_key="fine_label",
30
+ max_samples=1000,
31
+ )
32
+ ```
33
+
34
+ ### From Directory
35
+ ```python
36
+ dataset.add_images_dir("/path/to/images", label_from_folder=True)
37
+ ```
38
+
39
+ ## Persistence Model: Additive
40
+
41
+ HyperView uses an **additive** persistence model:
42
+
43
+ | Action | Behavior |
44
+ |--------|----------|
45
+ | Add samples | New samples inserted, existing skipped by ID |
46
+ | Request fewer than exist | Existing samples preserved (no deletion) |
47
+ | Request more than exist | Only new samples added |
48
+ | Embeddings | Cached per-sample, reused across sessions |
49
+ | Projections | Recomputed when new samples added (UMAP requires refit) |
50
+
51
+ **Example:**
52
+ ```python
53
+ dataset = hv.Dataset("my_dataset")
54
+
55
+ dataset.add_from_huggingface(..., max_samples=200) # 200 samples
56
+ dataset.add_from_huggingface(..., max_samples=400) # +200 new → 400 total
57
+ dataset.add_from_huggingface(..., max_samples=300) # no change → 400 total
58
+ dataset.add_from_huggingface(..., max_samples=500) # +100 new → 500 total
59
+ ```
60
+
61
+ Samples are **never implicitly deleted**. Use `hv.Dataset.delete("name")` for explicit removal.
62
+
63
+ ## Computing Embeddings
64
+
65
+ ```python
66
+ # High-dimensional embeddings (CLIP/ResNet)
67
+ dataset.compute_embeddings(model="clip", show_progress=True)
68
+
69
+ # 2D projections for visualization
70
+ dataset.compute_visualization() # UMAP to Euclidean + Hyperbolic
71
+ ```
72
+
73
+ Embeddings are stored per-sample. If a sample already has embeddings, it's skipped.
74
+
75
+ ## Listing & Deleting Datasets
76
+
77
+ ```python
78
+ # List all persistent datasets
79
+ hv.Dataset.list_datasets() # ['cifar100_demo', 'my_dataset', ...]
80
+
81
+ # Delete a dataset
82
+ hv.Dataset.delete("my_dataset")
83
+
84
+ # Check existence
85
+ hv.Dataset.exists("my_dataset") # True/False
86
+ ```
87
+
88
+ ## Dataset Info
89
+
90
+ ```python
91
+ len(dataset) # Number of samples
92
+ dataset.name # Dataset name
93
+ dataset.labels # Unique labels
94
+ dataset.samples # Iterator over all samples
95
+ dataset[sample_id] # Get sample by ID
96
+ ```
docs/index.html ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HyperView: Interactive Poincaré Disk</title>
7
+ <style>
8
+ body {
9
+ margin: 0;
10
+ overflow: hidden;
11
+ background-color: #f0f2f5;
12
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ justify-content: center;
17
+ height: 100vh;
18
+ }
19
+ canvas {
20
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
21
+ border-radius: 50%;
22
+ background: white;
23
+ cursor: grab;
24
+ }
25
+ canvas:active {
26
+ cursor: grabbing;
27
+ }
28
+ .controls {
29
+ position: absolute;
30
+ top: 20px;
31
+ left: 20px;
32
+ background: rgba(255, 255, 255, 0.9);
33
+ padding: 15px;
34
+ border-radius: 8px;
35
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
36
+ max-width: 300px;
37
+ z-index: 100;
38
+ }
39
+ h1 { margin: 0 0 10px 0; font-size: 18px; color: #333; }
40
+ p { margin: 0 0 10px 0; font-size: 14px; color: #666; line-height: 1.4; }
41
+ .legend { display: flex; gap: 10px; font-size: 12px; margin-top: 10px; }
42
+ .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 5px; }
43
+
44
+ .mode-btn {
45
+ margin-top: 15px;
46
+ padding: 8px 16px;
47
+ background: #333;
48
+ color: white;
49
+ border: none;
50
+ border-radius: 4px;
51
+ cursor: pointer;
52
+ font-weight: bold;
53
+ width: 100%;
54
+ transition: background 0.3s;
55
+ }
56
+ .mode-btn:hover { background: #555; }
57
+ .mode-btn.active { background: #d93025; } /* Red for danger/collapse */
58
+
59
+ .status-box {
60
+ margin-top: 10px;
61
+ padding: 10px;
62
+ background: #f8f9fa;
63
+ border-left: 4px solid #ccc;
64
+ font-size: 13px;
65
+ transition: all 0.3s;
66
+ }
67
+ .status-box.collapse {
68
+ border-left-color: #d93025;
69
+ background: #fce8e6;
70
+ color: #a50e0e;
71
+ }
72
+ .status-box.expand {
73
+ border-left-color: #188038;
74
+ background: #e6f4ea;
75
+ color: #137333;
76
+ }
77
+
78
+ /* About Overlay Styles */
79
+ .about-btn {
80
+ position: absolute;
81
+ top: 20px;
82
+ right: 20px;
83
+ background: #fff;
84
+ border: 1px solid #ccc;
85
+ border-radius: 50%;
86
+ width: 40px;
87
+ height: 40px;
88
+ font-size: 20px;
89
+ cursor: pointer;
90
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ color: #555;
95
+ transition: all 0.2s;
96
+ z-index: 100;
97
+ }
98
+ .about-btn:hover { background: #f0f0f0; color: #333; }
99
+
100
+ .overlay-backdrop {
101
+ position: fixed;
102
+ top: 0;
103
+ left: 0;
104
+ width: 100%;
105
+ height: 100%;
106
+ background: rgba(0,0,0,0.5);
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ z-index: 1000;
111
+ opacity: 0;
112
+ visibility: hidden;
113
+ transition: opacity 0.3s, visibility 0.3s;
114
+ }
115
+ .overlay-backdrop.visible {
116
+ opacity: 1;
117
+ visibility: visible;
118
+ }
119
+ .overlay-content {
120
+ background: white;
121
+ padding: 30px;
122
+ border-radius: 12px;
123
+ max-width: 600px;
124
+ width: 90%;
125
+ max-height: 80vh;
126
+ overflow-y: auto;
127
+ box-shadow: 0 10px 25px rgba(0,0,0,0.2);
128
+ position: relative;
129
+ }
130
+ .close-btn {
131
+ position: absolute;
132
+ top: 15px;
133
+ right: 15px;
134
+ background: none;
135
+ border: none;
136
+ font-size: 24px;
137
+ cursor: pointer;
138
+ color: #999;
139
+ }
140
+ .close-btn:hover { color: #333; }
141
+
142
+ .overlay-content h2 { margin-top: 0; color: #333; }
143
+ .overlay-content h3 { color: #444; margin-top: 20px; margin-bottom: 10px; font-size: 16px; }
144
+ .overlay-content p { font-size: 15px; line-height: 1.6; color: #555; margin-bottom: 15px; }
145
+
146
+ .faq-item {
147
+ background: #f9f9f9;
148
+ padding: 15px;
149
+ border-radius: 8px;
150
+ margin-bottom: 15px;
151
+ border-left: 4px solid #0066cc;
152
+ }
153
+ .faq-item h4 { margin: 0 0 8px 0; color: #0066cc; font-size: 15px; }
154
+ .faq-item p { margin: 0; font-size: 14px; }
155
+ </style>
156
+ </head>
157
+ <body>
158
+ <button id="aboutBtn" class="about-btn" title="About HyperView">?</button>
159
+
160
+ <div id="aboutOverlay" class="overlay-backdrop">
161
+ <div class="overlay-content">
162
+ <button id="closeOverlay" class="close-btn">&times;</button>
163
+ <h2>Why HyperView?</h2>
164
+ <p>
165
+ Modern AI curation tools rely on <strong>Euclidean geometry</strong> (flat space).
166
+ But real-world data—like biological taxonomies, social hierarchies, and medical diagnoses—is
167
+ complex and hierarchical.
168
+ </p>
169
+ <p>
170
+ When you force this complex data into a flat box, you run out of room.
171
+ To fit the "Majority," the math crushes the "Minority" and "Rare" cases together.
172
+ We call this <strong>Representation Collapse</strong>.
173
+ </p>
174
+
175
+ <h3>The Solution: Hyperbolic Space</h3>
176
+ <p>
177
+ HyperView uses the <strong>Poincaré disk</strong>, a model of hyperbolic geometry where space expands exponentially towards the edge.
178
+ This gives "infinite" room for outliers, ensuring they remain distinct and visible.
179
+ </p>
180
+
181
+ <h3>FAQ: Why does this matter?</h3>
182
+
183
+ <div class="faq-item">
184
+ <h4>The "Hidden Diagnosis" Problem</h4>
185
+ <p>
186
+ Imagine training an AI doctor on 10,000 chest X-rays:
187
+ <br>• <strong>9,000 Healthy</strong> (Majority)
188
+ <br>• <strong>900 Common Pneumonia</strong> (Minority)
189
+ <br>• <strong>100 Rare Early-Stage Tuberculosis</strong> (Rare Subgroup)
190
+ </p>
191
+ <p style="margin-top: 10px;">
192
+ <strong>In Euclidean Space:</strong> The model runs out of room. It crushes the 100 Tuberculosis cases into the Pneumonia cluster. To the AI, they look like noise. The patient is misdiagnosed.
193
+ </p>
194
+ <p style="margin-top: 10px;">
195
+ <strong>In HyperView:</strong> The Tuberculosis cases are pushed to the edge. They form a distinct, visible island. You can see them, select them, and ensure the AI learns to save those patients.
196
+ </p>
197
+ </div>
198
+ </div>
199
+ </div>
200
+
201
+ <div class="controls">
202
+ <h1>HyperView Interactive Demo</h1>
203
+ <p>
204
+ <strong>Drag to Pan.</strong> Experience the "infinite" space.
205
+ Notice how the red "Rare" points expand and separate as you bring them towards the center.
206
+ </p>
207
+ <div class="legend">
208
+ <div><span class="dot" style="background: #ccc;"></span>Majority</div>
209
+ <div><span class="dot" style="background: #0066cc;"></span>Minority</div>
210
+ <div><span class="dot" style="background: #ff0000;"></span>Rare</div>
211
+ </div>
212
+
213
+ <div id="statusBox" class="status-box expand">
214
+ <strong>Hyperbolic Mode:</strong><br>
215
+ Space expands exponentially.<br>
216
+ Rare items are distinct.
217
+ </div>
218
+ <button id="toggleBtn" class="mode-btn">Simulate Euclidean Collapse</button>
219
+ </div>
220
+ <canvas id="poincareCanvas"></canvas>
221
+
222
+ <script>
223
+ const canvas = document.getElementById('poincareCanvas');
224
+ const ctx = canvas.getContext('2d');
225
+ const toggleBtn = document.getElementById('toggleBtn');
226
+ const statusBox = document.getElementById('statusBox');
227
+
228
+ // Configuration
229
+ const RADIUS = 300;
230
+ const WIDTH = RADIUS * 2;
231
+ const HEIGHT = RADIUS * 2;
232
+
233
+ canvas.width = WIDTH;
234
+ canvas.height = HEIGHT;
235
+
236
+ // Complex Number Utilities
237
+ class Complex {
238
+ constructor(re, im) { this.re = re; this.im = im; }
239
+
240
+ add(other) { return new Complex(this.re + other.re, this.im + other.im); }
241
+ sub(other) { return new Complex(this.re - other.re, this.im - other.im); }
242
+ mul(other) {
243
+ return new Complex(
244
+ this.re * other.re - this.im * other.im,
245
+ this.re * other.im + this.im * other.re
246
+ );
247
+ }
248
+ div(other) {
249
+ const denom = other.re * other.re + other.im * other.im;
250
+ return new Complex(
251
+ (this.re * other.re + this.im * other.im) / denom,
252
+ (this.im * other.re - this.re * other.im) / denom
253
+ );
254
+ }
255
+ conj() { return new Complex(this.re, -this.im); }
256
+ modSq() { return this.re * this.re + this.im * this.im; }
257
+ }
258
+
259
+ // Mobius Transformation: (z + a) / (1 + conj(a)z)
260
+ function mobiusAdd(z, a) {
261
+ const num = z.add(a);
262
+ const den = new Complex(1, 0).add(a.conj().mul(z));
263
+ return num.div(den);
264
+ }
265
+
266
+ // Data Generation (Hierarchy)
267
+ const points = [];
268
+
269
+ function addCluster(count, r_hyp, r_euc, theta_center, spread_hyp, spread_euc, type) {
270
+ for (let i = 0; i < count; i++) {
271
+ // Hyperbolic Position
272
+ const rh_noise = (Math.random() - 0.5) * spread_hyp;
273
+ const th_noise = (Math.random() - 0.5) * spread_hyp;
274
+ const rh = Math.min(0.99, Math.max(0, r_hyp + rh_noise));
275
+ const th = theta_center + th_noise;
276
+
277
+ // Euclidean Position (Crushed)
278
+ const re_noise = (Math.random() - 0.5) * spread_euc;
279
+ const re = Math.min(0.99, Math.max(0, r_euc + re_noise));
280
+
281
+ const hypZ = new Complex(rh * Math.cos(th), rh * Math.sin(th));
282
+ const eucZ = new Complex(re * Math.cos(th), re * Math.sin(th));
283
+
284
+ points.push({
285
+ hypZ: hypZ,
286
+ eucZ: eucZ,
287
+ currentZ: hypZ, // Start in Hyperbolic
288
+ type: type
289
+ });
290
+ }
291
+ }
292
+
293
+ // 1. Majority (Center)
294
+ addCluster(300, 0.1, 0.1, 0, 0.5, 0.2, 'majority');
295
+
296
+ // 2. Minority (Edge) - r=0.85 (Hyp) vs r=0.5 (Euc)
297
+ addCluster(50, 0.85, 0.5, Math.PI/4, 0.2, 0.1, 'minority');
298
+
299
+ // 3. Rare (Deep Edge) - r=0.95 (Hyp) vs r=0.52 (Euc - Overlapping Minority)
300
+ addCluster(10, 0.95, 0.52, Math.PI/4, 0.05, 0.02, 'rare');
301
+
302
+ // View State
303
+ let isEuclidean = false;
304
+ let animationProgress = 0; // 0 = Hyperbolic, 1 = Euclidean
305
+ let viewOffset = new Complex(0, 0);
306
+ let isDragging = false;
307
+ let lastMouse = null;
308
+
309
+ function screenToComplex(x, y) {
310
+ const rect = canvas.getBoundingClientRect();
311
+ const cx = x - rect.left - RADIUS;
312
+ const cy = y - rect.top - RADIUS;
313
+ return new Complex(cx / RADIUS, cy / RADIUS);
314
+ }
315
+
316
+ function lerpComplex(a, b, t) {
317
+ return new Complex(
318
+ a.re + (b.re - a.re) * t,
319
+ a.im + (b.im - a.im) * t
320
+ );
321
+ }
322
+
323
+
324
+ function update() {
325
+ // Animate transition
326
+ const target = isEuclidean ? 1 : 0;
327
+ const speed = 0.05;
328
+
329
+ if (Math.abs(animationProgress - target) > 0.001) {
330
+ animationProgress += (target - animationProgress) * speed;
331
+ } else {
332
+ animationProgress = target;
333
+ }
334
+
335
+ // Update point positions
336
+ points.forEach(p => {
337
+ // Interpolate between base positions
338
+ let basePos = lerpComplex(p.hypZ, p.eucZ, animationProgress);
339
+
340
+ // Apply View Transformation
341
+ // As we move to Euclidean, we want to reset the view to center (0,0)
342
+ // effectively disabling the "infinite scroll"
343
+ const effectiveViewOffset = new Complex(
344
+ viewOffset.re * (1 - animationProgress),
345
+ viewOffset.im * (1 - animationProgress)
346
+ );
347
+
348
+ p.currentZ = mobiusAdd(basePos, effectiveViewOffset);
349
+ });
350
+
351
+ draw();
352
+ requestAnimationFrame(update);
353
+ }
354
+
355
+ function draw() {
356
+ ctx.clearRect(0, 0, WIDTH, HEIGHT);
357
+
358
+ // Draw Disk Boundary
359
+ ctx.beginPath();
360
+ ctx.arc(RADIUS, RADIUS, RADIUS - 1, 0, Math.PI * 2);
361
+ ctx.strokeStyle = '#333';
362
+ ctx.lineWidth = 2;
363
+ ctx.stroke();
364
+ ctx.fillStyle = '#fff';
365
+ ctx.fill();
366
+
367
+ // Draw Grid (Geodesics)
368
+ ctx.strokeStyle = '#eee';
369
+ ctx.lineWidth = 1;
370
+ // Fade out grid in Euclidean mode
371
+ ctx.globalAlpha = 1 - animationProgress;
372
+ for(let r=0.2; r<1.0; r+=0.2) {
373
+ ctx.beginPath();
374
+ ctx.arc(RADIUS, RADIUS, r * RADIUS, 0, Math.PI * 2);
375
+ ctx.stroke();
376
+ }
377
+ ctx.globalAlpha = 1.0;
378
+
379
+ // Draw Points
380
+ points.forEach(p => {
381
+ const px = p.currentZ.re * RADIUS + RADIUS;
382
+ const py = p.currentZ.im * RADIUS + RADIUS;
383
+
384
+ ctx.beginPath();
385
+ ctx.arc(px, py, p.type === 'rare' ? 5 : 3, 0, Math.PI * 2);
386
+
387
+ if (p.type === 'majority') ctx.fillStyle = 'rgba(150, 150, 150, 0.5)';
388
+ if (p.type === 'minority') ctx.fillStyle = 'rgba(0, 102, 204, 0.8)';
389
+ if (p.type === 'rare') ctx.fillStyle = 'rgba(255, 0, 0, 1.0)';
390
+
391
+ ctx.fill();
392
+ });
393
+ }
394
+
395
+ // Interaction
396
+ canvas.addEventListener('mousedown', e => {
397
+ if (isEuclidean) return; // Disable drag in Euclidean mode
398
+ isDragging = true;
399
+ lastMouse = screenToComplex(e.clientX, e.clientY);
400
+ canvas.style.cursor = 'grabbing';
401
+ });
402
+
403
+ window.addEventListener('mouseup', () => {
404
+ isDragging = false;
405
+ canvas.style.cursor = isEuclidean ? 'not-allowed' : 'grab';
406
+ });
407
+
408
+ canvas.addEventListener('mousemove', e => {
409
+ if (!isDragging) return;
410
+
411
+ const currentMouse = screenToComplex(e.clientX, e.clientY);
412
+ const delta = currentMouse.sub(lastMouse);
413
+ const move = new Complex(delta.re, delta.im);
414
+
415
+ viewOffset = mobiusAdd(viewOffset, move);
416
+ lastMouse = currentMouse;
417
+ });
418
+
419
+ // Toggle Logic
420
+ toggleBtn.addEventListener('click', () => {
421
+ isEuclidean = !isEuclidean;
422
+
423
+ if (isEuclidean) {
424
+ toggleBtn.textContent = "Switch to Hyperbolic Mode";
425
+ toggleBtn.classList.add('active');
426
+ statusBox.innerHTML = "<strong>Euclidean Collapse:</strong><br>Rare items are crushed.<br>Indistinguishable from Minority.";
427
+ statusBox.className = "status-box collapse";
428
+ canvas.style.cursor = 'not-allowed';
429
+ } else {
430
+ toggleBtn.textContent = "Simulate Euclidean Collapse";
431
+ toggleBtn.classList.remove('active');
432
+ statusBox.innerHTML = "<strong>Hyperbolic Mode:</strong><br>Space expands exponentially.<br>Rare items are distinct.";
433
+ statusBox.className = "status-box expand";
434
+ canvas.style.cursor = 'grab';
435
+ }
436
+ });
437
+
438
+ // About Overlay Logic
439
+ const aboutBtn = document.getElementById('aboutBtn');
440
+ const aboutOverlay = document.getElementById('aboutOverlay');
441
+ const closeOverlay = document.getElementById('closeOverlay');
442
+
443
+ function openAbout() {
444
+ aboutOverlay.classList.add('visible');
445
+ }
446
+
447
+ function closeAbout() {
448
+ aboutOverlay.classList.remove('visible');
449
+ }
450
+
451
+ aboutBtn.addEventListener('click', openAbout);
452
+ closeOverlay.addEventListener('click', closeAbout);
453
+
454
+ // Close on click outside
455
+ aboutOverlay.addEventListener('click', (e) => {
456
+ if (e.target === aboutOverlay) {
457
+ closeAbout();
458
+ }
459
+ });
460
+
461
+ // Start Loop
462
+ update();
463
+ </script>
464
+ </body>
465
+ </html>
frontend/components.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "src/app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "aliases": {
15
+ "components": "@/components",
16
+ "utils": "@/lib/utils",
17
+ "ui": "@/components/ui",
18
+ "lib": "@/lib",
19
+ "hooks": "@/hooks"
20
+ },
21
+ "registries": {}
22
+ }
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+
4
+ import { FlatCompat } from "@eslint/eslintrc";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ // Bridge legacy shareable configs (like `next/core-web-vitals`) into ESLint v9 flat config.
10
+ const compat = new FlatCompat({
11
+ baseDirectory: __dirname,
12
+ });
13
+
14
+ const config = [
15
+ {
16
+ // Mirror Next.js defaults: never lint build artifacts.
17
+ ignores: ["**/.next/**", "**/out/**", "**/node_modules/**"],
18
+ },
19
+ ...compat.extends("next/core-web-vitals"),
20
+ ];
21
+
22
+ export default config;
frontend/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
frontend/next.config.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+ import path from "path";
3
+
4
+ const nextConfig: NextConfig = {
5
+ output: "export",
6
+ trailingSlash: true,
7
+ // Needed for Turbopack to resolve local linked/file dependencies in a monorepo.
8
+ outputFileTracingRoot: path.join(__dirname, ".."),
9
+ transpilePackages: ["hyper-scatter"],
10
+ images: {
11
+ unoptimized: true,
12
+ },
13
+ // Proxy API calls to backend during development
14
+ async rewrites() {
15
+ return [
16
+ {
17
+ source: "/api/:path*",
18
+ destination: "http://127.0.0.1:6262/api/:path*",
19
+ },
20
+ ];
21
+ },
22
+ };
23
+
24
+ export default nextConfig;
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hyperview-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint .",
10
+ "export": "next build && cp -r out/* ../src/hyperview/server/static/"
11
+ },
12
+ "dependencies": {
13
+ "@radix-ui/react-collapsible": "^1.1.12",
14
+ "@radix-ui/react-dialog": "^1.1.15",
15
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
16
+ "@radix-ui/react-popover": "^1.1.15",
17
+ "@radix-ui/react-radio-group": "^1.3.8",
18
+ "@radix-ui/react-scroll-area": "^1.2.10",
19
+ "@radix-ui/react-separator": "^1.1.8",
20
+ "@radix-ui/react-slot": "^1.2.4",
21
+ "@radix-ui/react-toggle": "^1.1.10",
22
+ "@radix-ui/react-toggle-group": "^1.1.11",
23
+ "@radix-ui/react-tooltip": "^1.2.8",
24
+ "@tanstack/react-virtual": "^3.10.9",
25
+ "class-variance-authority": "^0.7.1",
26
+ "clsx": "^2.1.1",
27
+ "cmdk": "^1.1.1",
28
+ "dockview": "^4.13.1",
29
+ "hyper-scatter": "https://github.com/Hyper3Labs/hyper-scatter/archive/14421d24ccf128392444175a23e74627c120905c.tar.gz",
30
+ "justified-layout": "^4.1.0",
31
+ "lucide-react": "^0.562.0",
32
+ "next": "^16.0.7",
33
+ "react": "18.3.1",
34
+ "react-dom": "18.3.1",
35
+ "tailwind-merge": "^3.4.0",
36
+ "tailwindcss-animate": "^1.0.7",
37
+ "zustand": "^5.0.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/justified-layout": "^4.1.4",
41
+ "@types/node": "^22.9.0",
42
+ "@types/react": "^18.3.12",
43
+ "@types/react-dom": "^18.3.1",
44
+ "autoprefixer": "^10.4.20",
45
+ "eslint": "^9.14.0",
46
+ "eslint-config-next": "15.0.3",
47
+ "postcss": "^8.4.49",
48
+ "tailwindcss": "^3.4.15",
49
+ "typescript": "^5.6.3"
50
+ }
51
+ }
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ autoprefixer: {},
6
+ },
7
+ };
8
+
9
+ export default config;
frontend/src/app/globals.css ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "dockview/dist/styles/dockview.css";
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ @layer base {
8
+ html,
9
+ body {
10
+ height: 100%;
11
+ margin: 0;
12
+ padding: 0;
13
+ overflow: hidden;
14
+ }
15
+
16
+ :root {
17
+ /* Rerun-inspired dark theme as default (dark-first) */
18
+ --background: 215 28% 7%; /* #0d1117 */
19
+ --foreground: 213 27% 92%; /* #e6edf3 */
20
+
21
+ --card: 215 21% 11%; /* #161b22 */
22
+ --card-foreground: 213 27% 92%;
23
+
24
+ --popover: 215 21% 11%;
25
+ --popover-foreground: 213 27% 92%;
26
+
27
+ --primary: 212 100% 67%; /* #58a6ff */
28
+ --primary-foreground: 215 28% 7%;
29
+
30
+ --secondary: 215 14% 17%; /* #21262d */
31
+ --secondary-foreground: 213 27% 92%;
32
+
33
+ --muted: 215 14% 17%;
34
+ --muted-foreground: 213 12% 58%; /* #8b949e */
35
+
36
+ --accent: 215 14% 17%;
37
+ --accent-foreground: 213 27% 92%;
38
+
39
+ --destructive: 0 62% 50%;
40
+ --destructive-foreground: 0 0% 98%;
41
+
42
+ --border: 215 14% 22%; /* #30363d */
43
+ --input: 215 14% 22%;
44
+ --ring: 212 100% 67%;
45
+
46
+ --radius: 0.375rem;
47
+
48
+ /* Custom Rerun-specific tokens */
49
+ --surface: 215 21% 11%; /* #161b22 */
50
+ --surface-light: 215 14% 17%; /* #21262d */
51
+ --surface-elevated: 215 14% 22%; /* #30363d */
52
+ --border-subtle: 215 14% 17%;
53
+ --text: 213 27% 92%; /* #e6edf3 */
54
+ --text-muted: 213 12% 58%; /* #8b949e */
55
+ --text-subtle: 215 10% 46%; /* #6e7681 */
56
+ --accent-cyan: 176 60% 53%; /* #39d3cc */
57
+ --accent-orange: 27 86% 59%; /* #f0883e */
58
+ }
59
+ }
60
+
61
+ @layer base {
62
+ * {
63
+ @apply border-border;
64
+ }
65
+ body {
66
+ @apply bg-background text-foreground;
67
+ font-size: 12px;
68
+ line-height: 16px;
69
+ letter-spacing: -0.15px;
70
+ font-weight: 500;
71
+ font-feature-settings: "liga" 1, "calt" 1;
72
+ -webkit-font-smoothing: antialiased;
73
+ -moz-osx-font-smoothing: grayscale;
74
+ }
75
+ }
76
+
77
+ /* Custom scrollbar - Rerun style: 6px bar, 2px inner margin, 2px outer margin */
78
+ ::-webkit-scrollbar {
79
+ width: 10px; /* 6px bar + 2px inner margin on each side */
80
+ height: 10px;
81
+ }
82
+
83
+ ::-webkit-scrollbar-track {
84
+ background: transparent;
85
+ }
86
+
87
+ ::-webkit-scrollbar-thumb {
88
+ background: hsl(var(--muted-foreground) / 0.2);
89
+ border-radius: 3px;
90
+ border: 2px solid transparent;
91
+ background-clip: padding-box;
92
+ }
93
+
94
+ ::-webkit-scrollbar-thumb:hover {
95
+ background: hsl(var(--muted-foreground) / 0.35);
96
+ border: 2px solid transparent;
97
+ background-clip: padding-box;
98
+ }
99
+
100
+ /* Firefox scrollbar */
101
+ * {
102
+ scrollbar-width: thin;
103
+ scrollbar-color: hsl(var(--muted-foreground) / 0.25) transparent;
104
+ }
105
+
106
+ /* Hide scrollbar for some elements */
107
+ .hide-scrollbar::-webkit-scrollbar {
108
+ display: none;
109
+ }
110
+
111
+ .hide-scrollbar {
112
+ -ms-overflow-style: none;
113
+ scrollbar-width: none;
114
+ }
115
+
116
+ /* Scroll containers inside panels should keep scrollbars inset */
117
+ .panel-scroll {
118
+ scrollbar-gutter: stable;
119
+ }
120
+
121
+ /* Dockview theme overrides (match HyperView tokens) */
122
+ .hyperview-dockview {
123
+ --dv-background-color: hsl(var(--background));
124
+ --dv-group-view-background-color: hsl(var(--card));
125
+ --dv-tabs-and-actions-container-background-color: hsl(var(--secondary));
126
+ --dv-activegroup-visiblepanel-tab-background-color: hsl(var(--card));
127
+ --dv-activegroup-hiddenpanel-tab-background-color: hsl(var(--secondary));
128
+ --dv-inactivegroup-visiblepanel-tab-background-color: hsl(var(--secondary));
129
+ --dv-inactivegroup-hiddenpanel-tab-background-color: hsl(var(--secondary));
130
+ --dv-activegroup-visiblepanel-tab-color: hsl(var(--foreground));
131
+ --dv-activegroup-hiddenpanel-tab-color: hsl(var(--muted-foreground));
132
+ --dv-inactivegroup-visiblepanel-tab-color: hsl(var(--muted-foreground));
133
+ --dv-inactivegroup-hiddenpanel-tab-color: hsl(var(--muted-foreground));
134
+ --dv-tabs-and-actions-container-font-size: 12px;
135
+ --dv-tabs-and-actions-container-height: 24px;
136
+ --dv-tab-font-size: 12px;
137
+ --dv-tabs-container-scrollbar-color: hsl(var(--muted-foreground) / 0.25);
138
+ --dv-scrollbar-background-color: hsl(var(--muted-foreground) / 0.25);
139
+ --dv-tab-divider-color: hsl(var(--border));
140
+ --dv-separator-border: transparent;
141
+ --dv-paneview-header-border-color: hsl(var(--border));
142
+ --dv-sash-color: hsl(var(--border));
143
+ --dv-icon-hover-background-color: hsl(var(--accent));
144
+ --dv-active-sash-color: hsl(var(--primary));
145
+ --dv-drag-over-background-color: hsl(var(--primary) / 0.15);
146
+ --dv-drag-over-border-color: hsl(var(--primary) / 0.6);
147
+ /* Remove tab margins for flush appearance */
148
+ --dv-tab-margin: 0;
149
+ }
150
+
151
+ /* Remove any gaps between panels - Rerun-style flush layout */
152
+ .hyperview-dockview .dv-groupview {
153
+ border: none;
154
+ }
155
+
156
+ .hyperview-dockview .dv-tabs-and-actions-container {
157
+ border-bottom: 1px solid hsl(var(--border));
158
+ }
159
+
160
+ .hyperview-dockview .dv-tab {
161
+ padding: 0 8px;
162
+ height: 100%;
163
+ display: flex;
164
+ align-items: center;
165
+ line-height: 16px;
166
+ }
167
+
168
+ .hyperview-dockview .dv-scrollable .dv-scrollbar-horizontal {
169
+ height: 6px;
170
+ border-radius: 3px;
171
+ }
172
+
173
+ /* Hide Dockview tab scrollbars (avoid extra bar under tabs) */
174
+ .hyperview-dockview .dv-tabs-container {
175
+ scrollbar-width: none;
176
+ }
177
+
178
+ .hyperview-dockview .dv-tabs-container::-webkit-scrollbar {
179
+ height: 0;
180
+ }
181
+
182
+ .hyperview-dockview .dv-scrollable .dv-scrollbar-horizontal {
183
+ display: none;
184
+ }
185
+
186
+ /* Sash styling: transparent hit area with centered 1px visible line via pseudo-element */
187
+ .hyperview-dockview .dv-split-view-container > .dv-sash-container > .dv-sash {
188
+ /* Keep the sash itself transparent - we'll draw the line with ::after */
189
+ background-color: transparent !important;
190
+ }
191
+
192
+ /* Horizontal sash (vertical divider line) */
193
+ .hyperview-dockview .dv-split-view-container.dv-horizontal > .dv-sash-container > .dv-sash::after {
194
+ content: "";
195
+ position: absolute;
196
+ top: 0;
197
+ bottom: 0;
198
+ left: 50%;
199
+ transform: translateX(-50%);
200
+ width: 1px;
201
+ background-color: hsl(var(--border));
202
+ pointer-events: none;
203
+ }
204
+
205
+ .hyperview-dockview .dv-split-view-container.dv-horizontal > .dv-sash-container > .dv-sash.dv-enabled:hover::after {
206
+ background-color: hsl(var(--primary));
207
+ }
208
+
209
+ /* Vertical sash (horizontal divider line) */
210
+ .hyperview-dockview .dv-split-view-container.dv-vertical > .dv-sash-container > .dv-sash::after {
211
+ content: "";
212
+ position: absolute;
213
+ left: 0;
214
+ right: 0;
215
+ top: 50%;
216
+ transform: translateY(-50%);
217
+ height: 1px;
218
+ background-color: hsl(var(--border));
219
+ pointer-events: none;
220
+ }
221
+
222
+ .hyperview-dockview .dv-split-view-container.dv-vertical > .dv-sash-container > .dv-sash.dv-enabled:hover::after {
223
+ background-color: hsl(var(--primary));
224
+ }
frontend/src/app/layout.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({
6
+ subsets: ["latin"],
7
+ display: "swap",
8
+ weight: ["400", "500"],
9
+ });
10
+
11
+ export const metadata: Metadata = {
12
+ title: "HyperView",
13
+ description: "Dataset visualization with hyperbolic embeddings",
14
+ };
15
+
16
+ export default function RootLayout({
17
+ children,
18
+ }: Readonly<{
19
+ children: React.ReactNode;
20
+ }>) {
21
+ return (
22
+ <html lang="en" className="h-full">
23
+ <body className={`${inter.className} antialiased h-full`}>{children}</body>
24
+ </html>
25
+ );
26
+ }
frontend/src/app/page.tsx ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useCallback, useMemo, useRef, useState } from "react";
4
+ import { Header } from "@/components";
5
+ import { DockviewWorkspace, DockviewProvider } from "@/components/DockviewWorkspace";
6
+ import { useStore } from "@/store/useStore";
7
+ import type { Sample } from "@/types";
8
+ import {
9
+ fetchDataset,
10
+ fetchSamples,
11
+ fetchSamplesBatch,
12
+ fetchLassoSelection,
13
+ } from "@/lib/api";
14
+
15
+ const SAMPLES_PER_PAGE = 100;
16
+
17
+ export default function Home() {
18
+ const {
19
+ samples,
20
+ totalSamples,
21
+ samplesLoaded,
22
+ setSamples,
23
+ appendSamples,
24
+ addSamplesIfMissing,
25
+ setDatasetInfo,
26
+ setIsLoading,
27
+ isLoading,
28
+ error,
29
+ setError,
30
+ selectedIds,
31
+ isLassoSelection,
32
+ selectionSource,
33
+ lassoQuery,
34
+ lassoSamples,
35
+ lassoTotal,
36
+ lassoIsLoading,
37
+ setLassoResults,
38
+ labelFilter,
39
+ } = useStore();
40
+
41
+ const [loadingMore, setLoadingMore] = useState(false);
42
+ const labelFilterRef = useRef<string | null>(labelFilter ?? null);
43
+
44
+ // Initial data load - runs once on mount
45
+ // Store setters are stable and don't need to be in deps
46
+ useEffect(() => {
47
+ const loadData = async () => {
48
+ setIsLoading(true);
49
+ setError(null);
50
+
51
+ try {
52
+ // Fetch dataset info and samples in parallel
53
+ const [datasetInfo, samplesRes] = await Promise.all([
54
+ fetchDataset(),
55
+ fetchSamples(0, SAMPLES_PER_PAGE),
56
+ ]);
57
+
58
+ setDatasetInfo(datasetInfo);
59
+ setSamples(samplesRes.samples, samplesRes.total);
60
+ } catch (err) {
61
+ console.error("Failed to load data:", err);
62
+ setError(err instanceof Error ? err.message : "Failed to load data");
63
+ } finally {
64
+ setIsLoading(false);
65
+ }
66
+ };
67
+
68
+ loadData();
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, []);
71
+
72
+ // Fetch selected samples that aren't already loaded
73
+ useEffect(() => {
74
+ const fetchSelectedSamples = async () => {
75
+ if (isLassoSelection) return;
76
+ if (selectedIds.size === 0) return;
77
+ if (selectionSource === "label") return;
78
+
79
+ // Find IDs that are selected but not in our samples array
80
+ const loadedIds = new Set(samples.map((s) => s.id));
81
+ const missingIds = Array.from(selectedIds).filter((id) => !loadedIds.has(id));
82
+
83
+ if (missingIds.length === 0) return;
84
+
85
+ try {
86
+ const fetchedSamples = await fetchSamplesBatch(missingIds);
87
+ addSamplesIfMissing(fetchedSamples);
88
+ } catch (err) {
89
+ console.error("Failed to fetch selected samples:", err);
90
+ }
91
+ };
92
+
93
+ fetchSelectedSamples();
94
+ }, [selectedIds, samples, addSamplesIfMissing, isLassoSelection, selectionSource]);
95
+
96
+ // Refetch samples when label filter changes (non-lasso mode)
97
+ useEffect(() => {
98
+ if (labelFilterRef.current === labelFilter) return;
99
+ if (isLassoSelection) return;
100
+
101
+ labelFilterRef.current = labelFilter ?? null;
102
+
103
+ let cancelled = false;
104
+ const run = async () => {
105
+ try {
106
+ const res = await fetchSamples(0, SAMPLES_PER_PAGE, labelFilter ?? undefined);
107
+ if (cancelled) return;
108
+ setSamples(res.samples, res.total);
109
+ } catch (err) {
110
+ if (cancelled) return;
111
+ console.error("Failed to load filtered samples:", err);
112
+ }
113
+ };
114
+
115
+ run();
116
+ return () => {
117
+ cancelled = true;
118
+ };
119
+ }, [isLassoSelection, labelFilter, setSamples]);
120
+
121
+ // Fetch initial lasso selection page when a new lasso query begins.
122
+ useEffect(() => {
123
+ if (!isLassoSelection) return;
124
+ if (!lassoQuery) return;
125
+ if (!lassoIsLoading) return;
126
+
127
+ const abort = new AbortController();
128
+
129
+ const run = async () => {
130
+ try {
131
+ const res = await fetchLassoSelection({
132
+ layoutKey: lassoQuery.layoutKey,
133
+ polygon: lassoQuery.polygon,
134
+ offset: 0,
135
+ limit: SAMPLES_PER_PAGE,
136
+ signal: abort.signal,
137
+ });
138
+ if (abort.signal.aborted) return;
139
+ setLassoResults(res.samples, res.total, false);
140
+ } catch (err) {
141
+ if (err instanceof DOMException && err.name === "AbortError") return;
142
+ console.error("Failed to fetch lasso selection:", err);
143
+ setLassoResults([], 0, false);
144
+ }
145
+ };
146
+
147
+ run();
148
+
149
+ return () => abort.abort();
150
+ }, [isLassoSelection, lassoIsLoading, lassoQuery, setLassoResults]);
151
+
152
+ // Load more samples
153
+ const loadMore = useCallback(async () => {
154
+ if (loadingMore) return;
155
+
156
+ if (isLassoSelection) {
157
+ if (!lassoQuery) return;
158
+ if (lassoIsLoading) return;
159
+ if (!lassoIsLoading && lassoSamples.length >= lassoTotal) return;
160
+
161
+ setLoadingMore(true);
162
+ try {
163
+ const res = await fetchLassoSelection({
164
+ layoutKey: lassoQuery.layoutKey,
165
+ polygon: lassoQuery.polygon,
166
+ offset: lassoSamples.length,
167
+ limit: SAMPLES_PER_PAGE,
168
+ });
169
+ setLassoResults(res.samples, res.total, true);
170
+ } catch (err) {
171
+ console.error("Failed to load more lasso samples:", err);
172
+ } finally {
173
+ setLoadingMore(false);
174
+ }
175
+ return;
176
+ }
177
+
178
+ if (samplesLoaded >= totalSamples) return;
179
+
180
+ setLoadingMore(true);
181
+ try {
182
+ const res = await fetchSamples(samplesLoaded, SAMPLES_PER_PAGE, labelFilter ?? undefined);
183
+ appendSamples(res.samples);
184
+ } catch (err) {
185
+ console.error("Failed to load more samples:", err);
186
+ } finally {
187
+ setLoadingMore(false);
188
+ }
189
+ }, [
190
+ loadingMore,
191
+ appendSamples,
192
+ isLassoSelection,
193
+ lassoIsLoading,
194
+ lassoQuery,
195
+ lassoSamples.length,
196
+ lassoTotal,
197
+ samplesLoaded,
198
+ totalSamples,
199
+ setLassoResults,
200
+ labelFilter,
201
+ ]);
202
+
203
+ const displayedSamples = useMemo(() => {
204
+ if (isLassoSelection) return lassoSamples;
205
+
206
+ // When a selection comes from the scatter plot, bring selected samples to the top
207
+ // so the user immediately sees what they clicked.
208
+ if (selectionSource === "scatter" && selectedIds.size > 0) {
209
+ const byId = new Map<string, Sample>();
210
+ for (const s of samples) byId.set(s.id, s);
211
+
212
+ const pinned: Sample[] = [];
213
+ for (const id of selectedIds) {
214
+ const s = byId.get(id);
215
+ if (s) pinned.push(s);
216
+ }
217
+
218
+ if (pinned.length > 0) {
219
+ const rest = samples.filter((s) => !selectedIds.has(s.id));
220
+ return [...pinned, ...rest];
221
+ }
222
+ }
223
+
224
+ return samples;
225
+ }, [isLassoSelection, lassoSamples, samples, selectedIds, selectionSource]);
226
+
227
+ const displayedTotal = isLassoSelection ? lassoTotal : totalSamples;
228
+ const displayedHasMore = isLassoSelection ? displayedSamples.length < displayedTotal : samplesLoaded < totalSamples;
229
+
230
+ if (error) {
231
+ return (
232
+ <div className="h-screen flex flex-col bg-background">
233
+ <Header />
234
+ <div className="flex-1 flex items-center justify-center">
235
+ <div className="text-center">
236
+ <div className="text-destructive text-lg mb-2">Error</div>
237
+ <div className="text-muted-foreground">{error}</div>
238
+ <p className="text-muted-foreground mt-4 text-sm">
239
+ Make sure the HyperView backend is running on port 6262.
240
+ </p>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ if (isLoading) {
248
+ return (
249
+ <div className="h-screen flex flex-col bg-background">
250
+ <Header />
251
+ <div className="flex-1 flex items-center justify-center">
252
+ <div className="text-center">
253
+ <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
254
+ <div className="text-muted-foreground">Loading dataset...</div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ return (
262
+ <DockviewProvider
263
+ samples={displayedSamples}
264
+ onLoadMore={loadMore}
265
+ hasMore={displayedHasMore}
266
+ >
267
+ <div className="h-screen flex flex-col bg-background">
268
+ <Header />
269
+
270
+ {/* Main content - dockable panels */}
271
+ <div className="flex-1 bg-background overflow-hidden">
272
+ <DockviewWorkspace />
273
+ </div>
274
+ </div>
275
+ </DockviewProvider>
276
+ );
277
+ }
frontend/src/components/DockviewWorkspace.tsx ADDED
@@ -0,0 +1,765 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ createContext,
9
+ useContext,
10
+ type ReactNode,
11
+ } from "react";
12
+ import {
13
+ DockviewReact,
14
+ type DockviewApi,
15
+ type DockviewReadyEvent,
16
+ type IDockviewPanelProps,
17
+ type IDockviewPanelHeaderProps,
18
+ type IWatermarkPanelProps,
19
+ themeAbyss,
20
+ } from "dockview";
21
+ import { Circle, Disc, Grid3X3 } from "lucide-react";
22
+
23
+ import type { Geometry, Sample } from "@/types";
24
+ import { useStore } from "@/store/useStore";
25
+ import { findLayoutByGeometry } from "@/lib/layouts";
26
+ import { ImageGrid } from "./ImageGrid";
27
+ import { ScatterPanel } from "./ScatterPanel";
28
+ import { ExplorerPanel } from "./ExplorerPanel";
29
+ import { PlaceholderPanel } from "./PlaceholderPanel";
30
+ import { HyperViewLogo } from "./icons";
31
+
32
+ const LAYOUT_STORAGE_KEY = "hyperview:dockview-layout:v4";
33
+
34
+ // Panel IDs
35
+ const PANEL = {
36
+ EXPLORER: "explorer",
37
+ GRID: "grid",
38
+ SCATTER_EUCLIDEAN: "scatter-euclidean",
39
+ SCATTER_POINCARE: "scatter-poincare",
40
+ SCATTER_DEFAULT: "scatter-default",
41
+ RIGHT_PLACEHOLDER: "right-placeholder",
42
+ BOTTOM_PLACEHOLDER: "bottom-placeholder",
43
+ } as const;
44
+
45
+ const CENTER_PANEL_IDS = [
46
+ PANEL.GRID,
47
+ PANEL.SCATTER_EUCLIDEAN,
48
+ PANEL.SCATTER_POINCARE,
49
+ PANEL.SCATTER_DEFAULT,
50
+ ] as const;
51
+
52
+ export const CENTER_PANEL_DEFS = [
53
+ { id: PANEL.GRID, label: "Samples", icon: Grid3X3 },
54
+ { id: PANEL.SCATTER_EUCLIDEAN, label: "Euclidean", icon: Circle },
55
+ { id: PANEL.SCATTER_POINCARE, label: "Hyperbolic", icon: Disc },
56
+ ] as const;
57
+
58
+ const NON_ANCHOR_PANEL_IDS = new Set<string>([
59
+ PANEL.EXPLORER,
60
+ PANEL.RIGHT_PLACEHOLDER,
61
+ PANEL.BOTTOM_PLACEHOLDER,
62
+ ]);
63
+
64
+ const DRAG_LOCKED_PANEL_IDS = new Set<string>([PANEL.EXPLORER]);
65
+
66
+ const DEFAULT_CONTAINER_WIDTH = 1200;
67
+ const DEFAULT_CONTAINER_HEIGHT = 800;
68
+ const MIN_SIDE_PANEL_WIDTH = 120;
69
+ const MIN_BOTTOM_PANEL_HEIGHT = 150;
70
+
71
+ const getContainerWidth = (api?: DockviewApi | null) =>
72
+ api?.width ??
73
+ (typeof window === "undefined" ? DEFAULT_CONTAINER_WIDTH : window.innerWidth);
74
+ const getContainerHeight = (api?: DockviewApi | null) =>
75
+ api?.height ??
76
+ (typeof window === "undefined" ? DEFAULT_CONTAINER_HEIGHT : window.innerHeight);
77
+
78
+ const getDefaultLeftPanelWidth = (screenWidth: number) =>
79
+ Math.round(Math.min(0.35 * screenWidth, 200));
80
+ const getDefaultRightPanelWidth = (screenWidth: number) =>
81
+ Math.round(Math.min(0.45 * screenWidth, 300));
82
+ const getDefaultBottomPanelHeight = (containerHeight: number) =>
83
+ Math.round(
84
+ Math.min(Math.max(0.25 * containerHeight, MIN_BOTTOM_PANEL_HEIGHT), 250)
85
+ );
86
+ const getBottomPanelMaxHeight = (containerHeight: number) =>
87
+ Math.round(
88
+ Math.max(containerHeight - MIN_BOTTOM_PANEL_HEIGHT, MIN_BOTTOM_PANEL_HEIGHT)
89
+ );
90
+
91
+ function getCenterAnchorPanel(api: DockviewApi) {
92
+ for (const id of CENTER_PANEL_IDS) {
93
+ const panel = api.getPanel(id);
94
+ if (panel) return panel;
95
+ }
96
+
97
+ const fallback = api.panels.find((panel) => !NON_ANCHOR_PANEL_IDS.has(panel.id));
98
+ return fallback ?? api.activePanel;
99
+ }
100
+
101
+ function getZonePosition(zone: "left" | "right" | "bottom") {
102
+ return { direction: zone === "bottom" ? "below" : zone };
103
+ }
104
+
105
+ function getCenterTabPosition(api: DockviewApi) {
106
+ const anchor = getCenterAnchorPanel(api);
107
+ if (!anchor) return undefined;
108
+ return { referencePanel: anchor, direction: "within" as const };
109
+ }
110
+
111
+ // -----------------------------------------------------------------------------
112
+ // Context for sharing dockview API across components
113
+ // -----------------------------------------------------------------------------
114
+ interface DockviewContextValue {
115
+ api: DockviewApi | null;
116
+ setApi: (api: DockviewApi) => void;
117
+ samples: Sample[];
118
+ onLoadMore: () => void;
119
+ hasMore: boolean;
120
+ }
121
+
122
+ const DockviewContext = createContext<DockviewContextValue | null>(null);
123
+
124
+ function useDockviewContext() {
125
+ const ctx = useContext(DockviewContext);
126
+ if (!ctx) throw new Error("useDockviewContext must be used within DockviewProvider");
127
+ return ctx;
128
+ }
129
+
130
+ // Public hook for components like Header
131
+ export function useDockviewApi() {
132
+ const ctx = useContext(DockviewContext);
133
+ const datasetInfo = useStore((state) => state.datasetInfo);
134
+ const {
135
+ leftPanelOpen,
136
+ rightPanelOpen,
137
+ bottomPanelOpen,
138
+ setLeftPanelOpen,
139
+ setRightPanelOpen,
140
+ setBottomPanelOpen,
141
+ } = useStore();
142
+
143
+ const addPanel = useCallback(
144
+ (panelId: string) => {
145
+ if (!ctx?.api) return;
146
+
147
+ const api = ctx.api;
148
+ const position = getCenterTabPosition(api);
149
+ const baseOptions = position ? { position } : {};
150
+
151
+ const layouts = datasetInfo?.layouts ?? [];
152
+ const euclideanLayout = findLayoutByGeometry(layouts, "euclidean");
153
+ const poincareLayout = findLayoutByGeometry(layouts, "poincare");
154
+
155
+ // Don't add if already exists - just focus it
156
+ if (api.getPanel(panelId)) {
157
+ api.getPanel(panelId)?.focus();
158
+ return;
159
+ }
160
+
161
+ switch (panelId) {
162
+ case PANEL.GRID:
163
+ api.addPanel({
164
+ id: PANEL.GRID,
165
+ component: "grid",
166
+ title: "Samples",
167
+ tabComponent: "samplesTab",
168
+ renderer: "always",
169
+ ...baseOptions,
170
+ });
171
+ break;
172
+
173
+ case PANEL.SCATTER_EUCLIDEAN:
174
+ api.addPanel({
175
+ id: PANEL.SCATTER_EUCLIDEAN,
176
+ component: "scatter",
177
+ title: "Euclidean",
178
+ tabComponent: "euclideanTab",
179
+ params: {
180
+ layoutKey: euclideanLayout?.layout_key,
181
+ geometry: "euclidean" as Geometry,
182
+ },
183
+ renderer: "always",
184
+ ...baseOptions,
185
+ });
186
+ break;
187
+
188
+ case PANEL.SCATTER_POINCARE:
189
+ api.addPanel({
190
+ id: PANEL.SCATTER_POINCARE,
191
+ component: "scatter",
192
+ title: "Hyperbolic",
193
+ tabComponent: "hyperbolicTab",
194
+ params: {
195
+ layoutKey: poincareLayout?.layout_key,
196
+ geometry: "poincare" as Geometry,
197
+ },
198
+ renderer: "always",
199
+ ...baseOptions,
200
+ });
201
+ break;
202
+ }
203
+ },
204
+ [ctx?.api, datasetInfo]
205
+ );
206
+
207
+ const resetLayout = useCallback(() => {
208
+ localStorage.removeItem(LAYOUT_STORAGE_KEY);
209
+ window.location.reload();
210
+ }, []);
211
+
212
+ // Toggle zone visibility
213
+ const toggleZone = useCallback(
214
+ (zone: "left" | "right" | "bottom") => {
215
+ if (!ctx?.api) return;
216
+
217
+ const api = ctx.api;
218
+ const panelId =
219
+ zone === "left"
220
+ ? PANEL.EXPLORER
221
+ : zone === "right"
222
+ ? PANEL.RIGHT_PLACEHOLDER
223
+ : PANEL.BOTTOM_PLACEHOLDER;
224
+ const setOpen =
225
+ zone === "left"
226
+ ? setLeftPanelOpen
227
+ : zone === "right"
228
+ ? setRightPanelOpen
229
+ : setBottomPanelOpen;
230
+ const isOpen =
231
+ zone === "left"
232
+ ? leftPanelOpen
233
+ : zone === "right"
234
+ ? rightPanelOpen
235
+ : bottomPanelOpen;
236
+
237
+ const existingPanel = api.getPanel(panelId);
238
+
239
+ if (isOpen && existingPanel) {
240
+ existingPanel.api.close();
241
+ setOpen(false);
242
+ return;
243
+ }
244
+
245
+ if (isOpen) return;
246
+
247
+ const containerWidth = getContainerWidth(api);
248
+ const containerHeight = getContainerHeight(api);
249
+ const position = getZonePosition(zone);
250
+
251
+ let newPanel;
252
+ if (zone === "left") {
253
+ const targetWidth = getDefaultLeftPanelWidth(containerWidth);
254
+ newPanel = api.addPanel({
255
+ id: panelId,
256
+ component: "explorer",
257
+ title: "Explorer",
258
+ position,
259
+ initialWidth: targetWidth,
260
+ minimumWidth: MIN_SIDE_PANEL_WIDTH,
261
+ maximumWidth: targetWidth,
262
+ });
263
+
264
+ if (newPanel) {
265
+ newPanel.group.locked = true;
266
+ newPanel.group.header.hidden = true;
267
+ // Explicitly set the width to ensure it's applied
268
+ newPanel.api.setSize({ width: targetWidth });
269
+ }
270
+ } else if (zone === "right") {
271
+ newPanel = api.addPanel({
272
+ id: panelId,
273
+ component: "placeholder",
274
+ title: "Blank",
275
+ position,
276
+ initialWidth: getDefaultRightPanelWidth(containerWidth),
277
+ minimumWidth: MIN_SIDE_PANEL_WIDTH,
278
+ maximumWidth: Math.round(containerWidth * 0.65),
279
+ });
280
+ } else {
281
+ newPanel = api.addPanel({
282
+ id: panelId,
283
+ component: "placeholder",
284
+ title: "Blank",
285
+ position,
286
+ initialHeight: getDefaultBottomPanelHeight(containerHeight),
287
+ minimumHeight: MIN_BOTTOM_PANEL_HEIGHT,
288
+ maximumHeight: getBottomPanelMaxHeight(containerHeight),
289
+ });
290
+ }
291
+
292
+ if (newPanel) {
293
+ setOpen(true);
294
+ // Activate the panel so its content renders immediately
295
+ newPanel.api.setActive();
296
+ }
297
+ },
298
+ [
299
+ ctx?.api,
300
+ leftPanelOpen,
301
+ rightPanelOpen,
302
+ bottomPanelOpen,
303
+ setLeftPanelOpen,
304
+ setRightPanelOpen,
305
+ setBottomPanelOpen,
306
+ ]
307
+ );
308
+
309
+ if (!ctx) return null;
310
+
311
+ return {
312
+ api: ctx.api,
313
+ addPanel,
314
+ resetLayout,
315
+ toggleZone,
316
+ };
317
+ }
318
+
319
+ // -----------------------------------------------------------------------------
320
+ // Panel Components - stable references defined outside component
321
+ // -----------------------------------------------------------------------------
322
+ type ScatterPanelParams = {
323
+ layoutKey?: string;
324
+ geometry?: Geometry;
325
+ };
326
+
327
+ const ScatterDockPanel = React.memo(function ScatterDockPanel(
328
+ props: IDockviewPanelProps<ScatterPanelParams>
329
+ ) {
330
+ const params = props.params ?? {};
331
+ return (
332
+ <ScatterPanel
333
+ className="h-full"
334
+ layoutKey={params.layoutKey}
335
+ geometry={params.geometry}
336
+ />
337
+ );
338
+ });
339
+
340
+ // Custom tab component with icon (like Rerun's "Image and segmentation mask" tab)
341
+ type TabWithIconProps = IDockviewPanelHeaderProps & {
342
+ icon: React.ReactNode;
343
+ };
344
+
345
+ const TabWithIcon = React.memo(function TabWithIcon({ api, icon }: TabWithIconProps) {
346
+ return (
347
+ <div className="flex items-center gap-1 text-[12px] leading-[16px] font-medium tracking-[-0.15px]">
348
+ <span className="flex-shrink-0">{icon}</span>
349
+ <span className="truncate">{api.title}</span>
350
+ </div>
351
+ );
352
+ });
353
+
354
+ // Tab components for different panel types
355
+ const EuclideanTab = React.memo(function EuclideanTab(props: IDockviewPanelHeaderProps) {
356
+ return <TabWithIcon {...props} icon={<Circle className="h-3.5 w-3.5" />} />;
357
+ });
358
+
359
+ const HyperbolicTab = React.memo(function HyperbolicTab(props: IDockviewPanelHeaderProps) {
360
+ return <TabWithIcon {...props} icon={<Disc className="h-3.5 w-3.5" />} />;
361
+ });
362
+
363
+ const SamplesTab = React.memo(function SamplesTab(props: IDockviewPanelHeaderProps) {
364
+ return <TabWithIcon {...props} icon={<Grid3X3 className="h-3.5 w-3.5" />} />;
365
+ });
366
+
367
+ // Grid panel uses context to get samples
368
+ const GridDockPanel = React.memo(function GridDockPanel() {
369
+ const ctx = useDockviewContext();
370
+ return (
371
+ <ImageGrid
372
+ samples={ctx.samples}
373
+ onLoadMore={ctx.onLoadMore}
374
+ hasMore={ctx.hasMore}
375
+ />
376
+ );
377
+ });
378
+
379
+ // Explorer panel for left zone
380
+ const ExplorerDockPanel = React.memo(function ExplorerDockPanel() {
381
+ return <ExplorerPanel />;
382
+ });
383
+
384
+ // Placeholder panel for right/bottom zones
385
+ const PlaceholderDockPanel = React.memo(function PlaceholderDockPanel(
386
+ props: IDockviewPanelProps
387
+ ) {
388
+ const handleClose = React.useCallback(() => {
389
+ props.api.close();
390
+ }, [props.api]);
391
+
392
+ return <PlaceholderPanel onClose={handleClose} />;
393
+ });
394
+
395
+ // Watermark shown when dock is empty - just the logo, no text
396
+ const Watermark = React.memo(function Watermark(_props: IWatermarkPanelProps) {
397
+ return (
398
+ <div className="flex items-center justify-center h-full w-full">
399
+ <div className="text-muted-foreground/20">
400
+ <HyperViewLogo className="w-16 h-16" />
401
+ </div>
402
+ </div>
403
+ );
404
+ });
405
+
406
+ // Stable components object - never changes
407
+ const COMPONENTS = {
408
+ grid: GridDockPanel,
409
+ scatter: ScatterDockPanel,
410
+ explorer: ExplorerDockPanel,
411
+ placeholder: PlaceholderDockPanel,
412
+ };
413
+
414
+ // Tab components with icons
415
+ const TAB_COMPONENTS = {
416
+ euclideanTab: EuclideanTab,
417
+ hyperbolicTab: HyperbolicTab,
418
+ samplesTab: SamplesTab,
419
+ };
420
+
421
+ // -----------------------------------------------------------------------------
422
+ // Provider Component
423
+ // -----------------------------------------------------------------------------
424
+ interface DockviewProviderProps {
425
+ children: ReactNode;
426
+ samples: Sample[];
427
+ onLoadMore: () => void;
428
+ hasMore: boolean;
429
+ }
430
+
431
+ export function DockviewProvider({
432
+ children,
433
+ samples,
434
+ onLoadMore,
435
+ hasMore,
436
+ }: DockviewProviderProps) {
437
+ const [api, setApi] = useState<DockviewApi | null>(null);
438
+
439
+ const contextValue = useMemo(
440
+ () => ({
441
+ api,
442
+ setApi,
443
+ samples,
444
+ onLoadMore,
445
+ hasMore,
446
+ }),
447
+ [api, samples, onLoadMore, hasMore]
448
+ );
449
+
450
+ return (
451
+ <DockviewContext.Provider value={contextValue}>
452
+ {children}
453
+ </DockviewContext.Provider>
454
+ );
455
+ }
456
+
457
+ function applyZonePolicies(api: DockviewApi) {
458
+ const explorer = api.getPanel(PANEL.EXPLORER);
459
+ if (explorer) {
460
+ explorer.group.locked = true;
461
+ explorer.group.header.hidden = true;
462
+ }
463
+
464
+ // Hide tab headers for placeholder panels
465
+ const rightPlaceholder = api.getPanel(PANEL.RIGHT_PLACEHOLDER);
466
+ if (rightPlaceholder) {
467
+ rightPlaceholder.group.header.hidden = true;
468
+ }
469
+
470
+ const bottomPlaceholder = api.getPanel(PANEL.BOTTOM_PLACEHOLDER);
471
+ if (bottomPlaceholder) {
472
+ bottomPlaceholder.group.header.hidden = true;
473
+ }
474
+ }
475
+
476
+ // -----------------------------------------------------------------------------
477
+ // Workspace Component - the actual dockview renderer
478
+ // -----------------------------------------------------------------------------
479
+ export function DockviewWorkspace() {
480
+ const ctx = useDockviewContext();
481
+ const datasetInfo = useStore((state) => state.datasetInfo);
482
+ const { setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen } = useStore();
483
+
484
+ const buildDefaultLayout = useCallback(
485
+ (api: DockviewApi) => {
486
+ const layouts = datasetInfo?.layouts ?? [];
487
+ const euclideanLayout = findLayoutByGeometry(layouts, "euclidean");
488
+ const poincareLayout = findLayoutByGeometry(layouts, "poincare");
489
+ const fallbackLayout = !euclideanLayout && !poincareLayout ? layouts[0] : null;
490
+ const hasLayouts = layouts.length > 0;
491
+
492
+ // Create the grid panel first (center zone)
493
+ const gridPanel =
494
+ api.getPanel(PANEL.GRID) ??
495
+ api.addPanel({
496
+ id: PANEL.GRID,
497
+ component: "grid",
498
+ title: "Samples",
499
+ tabComponent: "samplesTab",
500
+ renderer: "always",
501
+ });
502
+
503
+ let scatterPanel: typeof gridPanel | null = null;
504
+
505
+ if (hasLayouts && euclideanLayout) {
506
+ scatterPanel =
507
+ api.getPanel(PANEL.SCATTER_EUCLIDEAN) ??
508
+ api.addPanel({
509
+ id: PANEL.SCATTER_EUCLIDEAN,
510
+ component: "scatter",
511
+ title: "Euclidean",
512
+ tabComponent: "euclideanTab",
513
+ params: {
514
+ layoutKey: euclideanLayout.layout_key,
515
+ geometry: "euclidean" as Geometry,
516
+ },
517
+ position: {
518
+ referencePanel: gridPanel.id,
519
+ direction: "right",
520
+ },
521
+ renderer: "always",
522
+ });
523
+ }
524
+
525
+ if (hasLayouts && poincareLayout) {
526
+ const position = scatterPanel
527
+ ? { referencePanel: scatterPanel.id, direction: "within" as const }
528
+ : { referencePanel: gridPanel.id, direction: "right" as const };
529
+
530
+ const poincarePanel =
531
+ api.getPanel(PANEL.SCATTER_POINCARE) ??
532
+ api.addPanel({
533
+ id: PANEL.SCATTER_POINCARE,
534
+ component: "scatter",
535
+ title: "Hyperbolic",
536
+ tabComponent: "hyperbolicTab",
537
+ params: {
538
+ layoutKey: poincareLayout.layout_key,
539
+ geometry: "poincare" as Geometry,
540
+ },
541
+ position,
542
+ renderer: "always",
543
+ });
544
+
545
+ if (!scatterPanel) {
546
+ scatterPanel = poincarePanel;
547
+ }
548
+ }
549
+
550
+ if (!hasLayouts) {
551
+ const euclideanPanel =
552
+ api.getPanel(PANEL.SCATTER_EUCLIDEAN) ??
553
+ api.addPanel({
554
+ id: PANEL.SCATTER_EUCLIDEAN,
555
+ component: "scatter",
556
+ title: "Euclidean",
557
+ tabComponent: "euclideanTab",
558
+ params: {
559
+ geometry: "euclidean" as Geometry,
560
+ },
561
+ position: {
562
+ referencePanel: gridPanel.id,
563
+ direction: "right",
564
+ },
565
+ renderer: "always",
566
+ });
567
+
568
+ api.getPanel(PANEL.SCATTER_POINCARE) ??
569
+ api.addPanel({
570
+ id: PANEL.SCATTER_POINCARE,
571
+ component: "scatter",
572
+ title: "Hyperbolic",
573
+ tabComponent: "hyperbolicTab",
574
+ params: {
575
+ geometry: "poincare" as Geometry,
576
+ },
577
+ position: {
578
+ referencePanel: euclideanPanel.id,
579
+ direction: "within" as const,
580
+ },
581
+ renderer: "always",
582
+ });
583
+
584
+ scatterPanel = euclideanPanel;
585
+ }
586
+
587
+ if (fallbackLayout && !scatterPanel) {
588
+ api.getPanel(PANEL.SCATTER_DEFAULT) ??
589
+ api.addPanel({
590
+ id: PANEL.SCATTER_DEFAULT,
591
+ component: "scatter",
592
+ title: "Embeddings",
593
+ params: {
594
+ layoutKey: fallbackLayout.layout_key,
595
+ },
596
+ position: {
597
+ referencePanel: gridPanel.id,
598
+ direction: "right",
599
+ },
600
+ renderer: "always",
601
+ });
602
+ }
603
+
604
+ const containerWidth = getContainerWidth(api);
605
+ const explorerPanel =
606
+ api.getPanel(PANEL.EXPLORER) ??
607
+ api.addPanel({
608
+ id: PANEL.EXPLORER,
609
+ component: "explorer",
610
+ title: "Explorer",
611
+ position: getZonePosition("left"),
612
+ initialWidth: getDefaultLeftPanelWidth(containerWidth),
613
+ minimumWidth: MIN_SIDE_PANEL_WIDTH,
614
+ maximumWidth: getDefaultLeftPanelWidth(containerWidth),
615
+ inactive: true,
616
+ });
617
+
618
+ if (explorerPanel) {
619
+ explorerPanel.group.locked = true;
620
+ explorerPanel.group.header.hidden = true;
621
+ }
622
+
623
+ setLeftPanelOpen(!!explorerPanel);
624
+ setRightPanelOpen(false);
625
+ setBottomPanelOpen(false);
626
+ },
627
+ [datasetInfo, setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen]
628
+ );
629
+
630
+ const onReady = useCallback(
631
+ (event: DockviewReadyEvent) => {
632
+ ctx.setApi(event.api);
633
+
634
+ const stored = localStorage.getItem(LAYOUT_STORAGE_KEY);
635
+ if (stored) {
636
+ try {
637
+ event.api.fromJSON(JSON.parse(stored));
638
+ if (event.api.totalPanels === 0) {
639
+ localStorage.removeItem(LAYOUT_STORAGE_KEY);
640
+ buildDefaultLayout(event.api);
641
+ }
642
+
643
+ // Re-apply side-zone policies after restore (header hidden, no-drop targets, etc)
644
+ applyZonePolicies(event.api);
645
+
646
+ // Sync store state with restored layout
647
+ setLeftPanelOpen(!!event.api.getPanel(PANEL.EXPLORER));
648
+ setRightPanelOpen(!!event.api.getPanel(PANEL.RIGHT_PLACEHOLDER));
649
+ setBottomPanelOpen(!!event.api.getPanel(PANEL.BOTTOM_PLACEHOLDER));
650
+ return;
651
+ } catch (err) {
652
+ console.warn("Failed to restore dock layout, resetting.", err);
653
+ localStorage.removeItem(LAYOUT_STORAGE_KEY);
654
+ }
655
+ }
656
+
657
+ if (event.api.totalPanels === 0) {
658
+ buildDefaultLayout(event.api);
659
+ }
660
+ },
661
+ [buildDefaultLayout, ctx, setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen]
662
+ );
663
+
664
+ // Save layout on changes
665
+ useEffect(() => {
666
+ const api = ctx.api;
667
+ if (!api) return;
668
+
669
+ const disposable = api.onDidLayoutChange(() => {
670
+ if (api.totalPanels === 0) return;
671
+ const layout = api.toJSON();
672
+ localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout));
673
+ });
674
+
675
+ return () => disposable.dispose();
676
+ }, [ctx.api]);
677
+
678
+ // Sync panel state when panels are closed
679
+ useEffect(() => {
680
+ const api = ctx.api;
681
+ if (!api) return;
682
+
683
+ const disposable = api.onDidRemovePanel((e) => {
684
+ if (e.id === PANEL.EXPLORER) setLeftPanelOpen(false);
685
+ if (e.id === PANEL.RIGHT_PLACEHOLDER) setRightPanelOpen(false);
686
+ if (e.id === PANEL.BOTTOM_PLACEHOLDER) setBottomPanelOpen(false);
687
+ });
688
+
689
+ return () => disposable.dispose();
690
+ }, [ctx.api, setLeftPanelOpen, setRightPanelOpen, setBottomPanelOpen]);
691
+
692
+ // When a real panel is dropped into a placeholder group, close the placeholder
693
+ useEffect(() => {
694
+ const api = ctx.api;
695
+ if (!api) return;
696
+
697
+ const disposable = api.onDidAddPanel((e) => {
698
+ // Skip if the added panel is a placeholder itself
699
+ if (e.id === PANEL.RIGHT_PLACEHOLDER || e.id === PANEL.BOTTOM_PLACEHOLDER) {
700
+ return;
701
+ }
702
+
703
+ // Check if this panel was added to the same group as a placeholder
704
+ const group = e.group;
705
+ if (!group) return;
706
+
707
+ // Find and close any placeholder panels in the same group
708
+ const rightPlaceholder = api.getPanel(PANEL.RIGHT_PLACEHOLDER);
709
+ const bottomPlaceholder = api.getPanel(PANEL.BOTTOM_PLACEHOLDER);
710
+
711
+ if (rightPlaceholder && rightPlaceholder.group?.id === group.id) {
712
+ rightPlaceholder.api.close();
713
+ }
714
+ if (bottomPlaceholder && bottomPlaceholder.group?.id === group.id) {
715
+ bottomPlaceholder.api.close();
716
+ }
717
+ });
718
+
719
+ return () => disposable.dispose();
720
+ }, [ctx.api]);
721
+
722
+ // Prevent dragging locked panels (explorer only)
723
+ useEffect(() => {
724
+ const api = ctx.api;
725
+ if (!api) return;
726
+
727
+ const disposable = api.onWillDragPanel((event) => {
728
+ if (DRAG_LOCKED_PANEL_IDS.has(event.panel.id)) {
729
+ event.nativeEvent.preventDefault();
730
+ }
731
+ });
732
+
733
+ return () => disposable.dispose();
734
+ }, [ctx.api]);
735
+
736
+ // Rebuild layout when dataset info changes
737
+ useEffect(() => {
738
+ if (!ctx.api) return;
739
+ if (!datasetInfo) return;
740
+
741
+ const hasScatter =
742
+ ctx.api.getPanel(PANEL.SCATTER_EUCLIDEAN) ||
743
+ ctx.api.getPanel(PANEL.SCATTER_POINCARE) ||
744
+ ctx.api.getPanel(PANEL.SCATTER_DEFAULT);
745
+
746
+ if (!hasScatter) {
747
+ buildDefaultLayout(ctx.api);
748
+ }
749
+ }, [buildDefaultLayout, datasetInfo, ctx.api]);
750
+
751
+ return (
752
+ <div className="h-full w-full">
753
+ <DockviewReact
754
+ className="dockview-theme-abyss hyperview-dockview"
755
+ components={COMPONENTS}
756
+ tabComponents={TAB_COMPONENTS}
757
+ onReady={onReady}
758
+ theme={themeAbyss}
759
+ defaultRenderer="always"
760
+ scrollbars="native"
761
+ watermarkComponent={Watermark}
762
+ />
763
+ </div>
764
+ );
765
+ }
frontend/src/components/ExplorerPanel.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useStore } from "@/store/useStore";
5
+ import { Panel } from "./Panel";
6
+ import { PanelHeader } from "./PanelHeader";
7
+ import { Tag, Search, ChevronDown, ChevronRight } from "lucide-react";
8
+ import { cn } from "@/lib/utils";
9
+ import { FALLBACK_LABEL_COLOR, MISSING_LABEL_COLOR, normalizeLabel } from "@/lib/labelColors";
10
+ import { useLabelLegend } from "./useLabelLegend";
11
+
12
+ interface ExplorerPanelProps {
13
+ className?: string;
14
+ }
15
+
16
+ export function ExplorerPanel({ className }: ExplorerPanelProps) {
17
+ const {
18
+ datasetInfo,
19
+ embeddingsByLayoutKey,
20
+ activeLayoutKey,
21
+ labelFilter,
22
+ setLabelFilter,
23
+ } = useStore();
24
+ const [labelSearch, setLabelSearch] = React.useState("");
25
+ const [isSearchOpen, setIsSearchOpen] = React.useState(false);
26
+ const [isLabelsExpanded, setIsLabelsExpanded] = React.useState(true);
27
+ const searchInputRef = React.useRef<HTMLInputElement>(null);
28
+
29
+ const resolvedLayoutKey =
30
+ activeLayoutKey ?? datasetInfo?.layouts?.[0]?.layout_key ?? null;
31
+ const embeddings = resolvedLayoutKey
32
+ ? embeddingsByLayoutKey[resolvedLayoutKey] ?? null
33
+ : null;
34
+
35
+ const {
36
+ labelCounts,
37
+ labelUniverse,
38
+ distinctLabelCount,
39
+ distinctColoringDisabled,
40
+ labelColorMap,
41
+ legendLabels,
42
+ } = useLabelLegend({ datasetInfo, embeddings, labelSearch, labelFilter });
43
+
44
+ const hasCounts = labelCounts.size > 0;
45
+ const baseLabelCount = labelUniverse.length > 0
46
+ ? labelUniverse.filter((label) => label !== "undefined").length
47
+ : distinctLabelCount;
48
+ const displayCount = labelSearch.trim().length > 0
49
+ ? legendLabels.length
50
+ : baseLabelCount;
51
+
52
+ const activeLabel = labelFilter ? normalizeLabel(labelFilter) : null;
53
+
54
+ // Focus search input when opened
55
+ React.useEffect(() => {
56
+ if (isSearchOpen && searchInputRef.current) {
57
+ searchInputRef.current.focus();
58
+ }
59
+ }, [isSearchOpen]);
60
+
61
+ const handleSearchToggle = () => {
62
+ setIsSearchOpen(!isSearchOpen);
63
+ if (isSearchOpen) {
64
+ setLabelSearch("");
65
+ }
66
+ };
67
+
68
+ return (
69
+ <Panel className={cn("h-full flex flex-col", className)}>
70
+ <PanelHeader title="Explorer" />
71
+
72
+ {/* Scrollable content area */}
73
+ <div className="flex-1 min-h-0 overflow-auto panel-scroll">
74
+ {/* Labels section */}
75
+ <div className="border-b border-border">
76
+ {/* Section header - collapsible with search icon */}
77
+ <div className="flex items-center h-6 px-2 bg-secondary/50">
78
+ <button
79
+ onClick={() => setIsLabelsExpanded(!isLabelsExpanded)}
80
+ className="flex items-center gap-1.5 flex-1 min-w-0 text-left hover:bg-muted/30 rounded px-1 -ml-1"
81
+ >
82
+ {isLabelsExpanded ? (
83
+ <ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
84
+ ) : (
85
+ <ChevronRight className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
86
+ )}
87
+ <Tag className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
88
+ <span className="text-xs text-muted-foreground truncate">
89
+ Labels
90
+ </span>
91
+ <span className="ml-auto text-xs text-muted-foreground/50 tabular-nums">
92
+ {displayCount}
93
+ </span>
94
+ </button>
95
+
96
+ {/* Search toggle button */}
97
+ <button
98
+ onClick={handleSearchToggle}
99
+ className={cn(
100
+ "h-5 w-5 flex items-center justify-center rounded text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors ml-1",
101
+ isSearchOpen && "text-muted-foreground bg-muted/50"
102
+ )}
103
+ title="Search labels"
104
+ >
105
+ <Search className="h-3 w-3" />
106
+ </button>
107
+ </div>
108
+
109
+ {/* Search input - shown when search is toggled */}
110
+ {isSearchOpen && (
111
+ <div className="px-2 py-1.5 bg-secondary/30 border-b border-border/50">
112
+ <input
113
+ ref={searchInputRef}
114
+ value={labelSearch}
115
+ onChange={(e) => setLabelSearch(e.target.value)}
116
+ placeholder="Filter labels..."
117
+ className="w-full h-6 px-2 rounded bg-background border border-border text-[12px] leading-[16px] text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring focus:border-ring"
118
+ />
119
+ </div>
120
+ )}
121
+
122
+ {/* Labels list - collapsible */}
123
+ {isLabelsExpanded && (
124
+ <div className="py-1">
125
+ {distinctColoringDisabled && (
126
+ <div className="px-3 py-1 text-[10px] text-muted-foreground/60">
127
+ Too many labels ({distinctLabelCount}) to color distinctly; using one color.
128
+ </div>
129
+ )}
130
+
131
+ {legendLabels.length === 0 ? (
132
+ <div className="px-3 py-2 text-[11px] text-muted-foreground/50">
133
+ No labels available
134
+ </div>
135
+ ) : (
136
+ <div className="space-y-px">
137
+ {legendLabels.map((label) => {
138
+ const color =
139
+ label === "undefined"
140
+ ? MISSING_LABEL_COLOR
141
+ : labelColorMap[label] ?? FALLBACK_LABEL_COLOR;
142
+ const normalized = normalizeLabel(label);
143
+ const isActive = activeLabel === normalized;
144
+ const isDimmed = activeLabel && !isActive;
145
+
146
+ return (
147
+ <button
148
+ key={label}
149
+ type="button"
150
+ onClick={() => setLabelFilter(isActive ? null : normalized)}
151
+ className={cn(
152
+ "flex items-center gap-2 w-full h-6 px-3 text-[12px] leading-[16px] text-left text-muted-foreground hover:text-foreground",
153
+ "hover:bg-muted/40 transition-colors",
154
+ isActive && "bg-muted/60 text-foreground",
155
+ isDimmed && "opacity-40"
156
+ )}
157
+ >
158
+ <span
159
+ className="w-2 h-2 rounded-full flex-shrink-0"
160
+ style={{ backgroundColor: color }}
161
+ />
162
+ <span className="truncate flex-1" title={label}>
163
+ {label}
164
+ </span>
165
+ {hasCounts && (
166
+ <span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums flex-shrink-0">
167
+ {labelCounts.get(label) ?? 0}
168
+ </span>
169
+ )}
170
+ </button>
171
+ );
172
+ })}
173
+ </div>
174
+ )}
175
+ </div>
176
+ )}
177
+ </div>
178
+ </div>
179
+ </Panel>
180
+ );
181
+ }
frontend/src/components/Header.tsx ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useStore } from "@/store/useStore";
4
+ import { Button } from "@/components/ui/button";
5
+ import { HyperViewLogo, DiscordIcon } from "./icons";
6
+ import { CENTER_PANEL_DEFS, useDockviewApi } from "./DockviewWorkspace";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuTrigger,
12
+ } from "@/components/ui/dropdown-menu";
13
+ import {
14
+ Popover,
15
+ PopoverContent,
16
+ PopoverTrigger,
17
+ } from "@/components/ui/popover";
18
+ import {
19
+ Command,
20
+ CommandEmpty,
21
+ CommandGroup,
22
+ CommandInput,
23
+ CommandItem,
24
+ CommandList,
25
+ } from "@/components/ui/command";
26
+ import {
27
+ ChevronDown,
28
+ RotateCcw,
29
+ Check,
30
+ PanelLeft,
31
+ PanelBottom,
32
+ PanelRight,
33
+ Settings,
34
+ Search,
35
+ } from "lucide-react";
36
+ import { useState } from "react";
37
+ import { cn } from "@/lib/utils";
38
+
39
+ const PANEL_CONFIG = CENTER_PANEL_DEFS;
40
+ const DISCORD_URL = process.env.NEXT_PUBLIC_DISCORD_URL ?? "https://discord.gg/Qf2pXtY4Vf";
41
+
42
+ export function Header() {
43
+ const { datasetInfo, leftPanelOpen, rightPanelOpen, bottomPanelOpen } = useStore();
44
+ const dockview = useDockviewApi();
45
+ const [datasetPickerOpen, setDatasetPickerOpen] = useState(false);
46
+
47
+ const handlePanelToggle = (panelId: string) => {
48
+ if (!dockview?.api) return;
49
+ const panel = dockview.api.getPanel(panelId);
50
+ if (panel) {
51
+ panel.api.close();
52
+ return;
53
+ }
54
+ dockview.addPanel(panelId);
55
+ };
56
+
57
+ // Check which panels are currently open
58
+ const openPanels = new Set(
59
+ PANEL_CONFIG.map((p) => p.id).filter((id) => dockview?.api?.getPanel(id))
60
+ );
61
+
62
+ return (
63
+ <header className="h-7 min-h-[28px] bg-secondary border-b border-border flex items-center justify-between px-2">
64
+ {/* Left side: Logo + View menu */}
65
+ <div className="flex items-center gap-2">
66
+ {/* Logo */}
67
+ <div className="flex items-center justify-center h-6 w-6 text-primary">
68
+ <HyperViewLogo className="h-3.5 w-3.5" />
69
+ </div>
70
+
71
+ {/* View dropdown */}
72
+ {dockview && (
73
+ <DropdownMenu>
74
+ <DropdownMenuTrigger asChild>
75
+ <Button
76
+ variant="ghost"
77
+ size="sm"
78
+ className="h-6 px-2 text-[12px] leading-[16px] tracking-[-0.15px] text-muted-foreground hover:text-foreground hover:bg-muted/50"
79
+ >
80
+ View
81
+ <ChevronDown className="ml-0.5 h-2.5 w-2.5" />
82
+ </Button>
83
+ </DropdownMenuTrigger>
84
+ <DropdownMenuContent align="start" className="w-48">
85
+ {/* Panel toggles - no section header, similar to Rerun */}
86
+ {PANEL_CONFIG.map((panel) => {
87
+ const Icon = panel.icon;
88
+ const isOpen = openPanels.has(panel.id);
89
+ return (
90
+ <DropdownMenuItem
91
+ key={panel.id}
92
+ onClick={() => handlePanelToggle(panel.id)}
93
+ className="flex items-center justify-between h-7 text-[12px] leading-[16px]"
94
+ >
95
+ <span className="flex items-center gap-2">
96
+ <Icon className="h-3.5 w-3.5" />
97
+ {panel.label}
98
+ </span>
99
+ {isOpen && <Check className="h-3.5 w-3.5 text-primary" />}
100
+ </DropdownMenuItem>
101
+ );
102
+ })}
103
+
104
+ {/* Spacer */}
105
+ <div className="h-2" />
106
+
107
+ {/* Reset layout */}
108
+ <DropdownMenuItem
109
+ onClick={() => dockview.resetLayout()}
110
+ className="flex items-center gap-2 h-7 text-[12px] leading-[16px]"
111
+ >
112
+ <RotateCcw className="h-3.5 w-3.5" />
113
+ Reset Layout
114
+ </DropdownMenuItem>
115
+ </DropdownMenuContent>
116
+ </DropdownMenu>
117
+ )}
118
+ </div>
119
+
120
+ {/* Center: Dataset picker (VS Code style command palette trigger) */}
121
+ <div className="flex-1 flex justify-center px-4">
122
+ <Popover open={datasetPickerOpen} onOpenChange={setDatasetPickerOpen}>
123
+ <PopoverTrigger asChild>
124
+ <Button
125
+ variant="ghost"
126
+ size="sm"
127
+ role="combobox"
128
+ aria-expanded={datasetPickerOpen}
129
+ className="h-6 min-w-[200px] max-w-[400px] px-3 text-[12px] leading-[16px] tracking-[-0.15px] text-muted-foreground hover:text-foreground bg-muted/40 hover:bg-muted/60 border border-border/50 rounded-md justify-start gap-2"
130
+ >
131
+ <Search className="h-3 w-3 flex-shrink-0 opacity-50" />
132
+ <span className="truncate flex-1 text-left">
133
+ {datasetInfo?.name ?? "No dataset loaded"}
134
+ </span>
135
+ </Button>
136
+ </PopoverTrigger>
137
+ <PopoverContent className="w-[280px] p-0" align="center">
138
+ <Command>
139
+ <CommandInput
140
+ placeholder="Search datasets..."
141
+ className="h-6 text-[12px] leading-[16px]"
142
+ />
143
+ <CommandList>
144
+ <CommandEmpty className="py-4 text-xs text-center">
145
+ No datasets found.
146
+ </CommandEmpty>
147
+ <CommandGroup>
148
+ {/* Currently only show the loaded dataset */}
149
+ {datasetInfo && (
150
+ <CommandItem
151
+ value={datasetInfo.name}
152
+ onSelect={() => setDatasetPickerOpen(false)}
153
+ className="text-[12px] leading-[16px]"
154
+ >
155
+ <span className="flex-1 truncate">{datasetInfo.name}</span>
156
+ <span className="text-[10px] text-muted-foreground ml-2">
157
+ {datasetInfo.num_samples.toLocaleString()} samples
158
+ </span>
159
+ <Check className="h-3 w-3 ml-2 text-primary" />
160
+ </CommandItem>
161
+ )}
162
+ </CommandGroup>
163
+ </CommandList>
164
+ </Command>
165
+ </PopoverContent>
166
+ </Popover>
167
+ </div>
168
+
169
+ {/* Right side: Discord + Panel toggles + Settings */}
170
+ <div className="flex items-center gap-0.5">
171
+ {/* Discord link */}
172
+ <a
173
+ href={DISCORD_URL}
174
+ target="_blank"
175
+ rel="noopener noreferrer"
176
+ className="h-6 w-6 p-0 flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
177
+ title="Join our Discord community"
178
+ >
179
+ <DiscordIcon className="h-3.5 w-3.5" />
180
+ </a>
181
+
182
+ {/* Separator */}
183
+ <div className="w-px h-3 bg-border mx-1" />
184
+
185
+ {/* Left panel toggle */}
186
+ <Button
187
+ variant="ghost"
188
+ size="sm"
189
+ onClick={() => dockview?.toggleZone("left")}
190
+ className={cn(
191
+ "h-6 w-6 p-0",
192
+ leftPanelOpen
193
+ ? "text-foreground bg-muted/50"
194
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/40"
195
+ )}
196
+ >
197
+ <PanelLeft className="h-3.5 w-3.5" />
198
+ </Button>
199
+
200
+ {/* Bottom panel toggle */}
201
+ <Button
202
+ variant="ghost"
203
+ size="sm"
204
+ onClick={() => dockview?.toggleZone("bottom")}
205
+ className={cn(
206
+ "h-6 w-6 p-0",
207
+ bottomPanelOpen
208
+ ? "text-foreground bg-muted/50"
209
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/40"
210
+ )}
211
+ >
212
+ <PanelBottom className="h-3.5 w-3.5" />
213
+ </Button>
214
+
215
+ {/* Right panel toggle */}
216
+ <Button
217
+ variant="ghost"
218
+ size="sm"
219
+ onClick={() => dockview?.toggleZone("right")}
220
+ className={cn(
221
+ "h-6 w-6 p-0",
222
+ rightPanelOpen
223
+ ? "text-foreground bg-muted/50"
224
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/40"
225
+ )}
226
+ >
227
+ <PanelRight className="h-3.5 w-3.5" />
228
+ </Button>
229
+
230
+ {/* Separator */}
231
+ <div className="w-px h-3 bg-border mx-1" />
232
+
233
+ {/* Settings button */}
234
+ <Button
235
+ variant="ghost"
236
+ size="sm"
237
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground hover:bg-muted/40"
238
+ >
239
+ <Settings className="h-3.5 w-3.5" />
240
+ </Button>
241
+ </div>
242
+ </header>
243
+ );
244
+ }
frontend/src/components/ImageGrid.tsx ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useVirtualizer } from "@tanstack/react-virtual";
5
+ import justifiedLayout from "justified-layout";
6
+ import { useStore } from "@/store/useStore";
7
+ import { Panel } from "./Panel";
8
+ import { CheckIcon } from "./icons";
9
+ import type { Sample } from "@/types";
10
+
11
+ interface ImageGridProps {
12
+ samples: Sample[];
13
+ onLoadMore?: () => void;
14
+ hasMore?: boolean;
15
+ }
16
+
17
+ // Justified layout config
18
+ const BOX_SPACING = 2; // Tight spacing between images
19
+ const TARGET_ROW_HEIGHT = 180; // Target height for rows
20
+ const DEFAULT_ASPECT_RATIO = 1; // Fallback for samples without dimensions
21
+
22
+ /**
23
+ * Get aspect ratio from sample, with fallback
24
+ */
25
+ function getAspectRatio(sample: Sample): number {
26
+ if (sample.width && sample.height && sample.height > 0) {
27
+ return sample.width / sample.height;
28
+ }
29
+ return DEFAULT_ASPECT_RATIO;
30
+ }
31
+
32
+ /**
33
+ * Compute justified layout geometry for samples
34
+ */
35
+ function computeLayout(
36
+ samples: Sample[],
37
+ containerWidth: number
38
+ ): { boxes: Array<{ width: number; height: number; top: number; left: number }>; containerHeight: number } {
39
+ if (samples.length === 0 || containerWidth <= 0) {
40
+ return { boxes: [], containerHeight: 0 };
41
+ }
42
+
43
+ const aspectRatios = samples.map(getAspectRatio);
44
+
45
+ const geometry = justifiedLayout(aspectRatios, {
46
+ containerWidth,
47
+ containerPadding: 0,
48
+ boxSpacing: BOX_SPACING,
49
+ targetRowHeight: TARGET_ROW_HEIGHT,
50
+ targetRowHeightTolerance: 0.25,
51
+ showWidows: true, // Always show last row even if incomplete
52
+ });
53
+
54
+ return {
55
+ boxes: geometry.boxes,
56
+ containerHeight: geometry.containerHeight,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Group boxes into rows for virtualization
62
+ */
63
+ interface RowData {
64
+ startIndex: number;
65
+ endIndex: number; // exclusive
66
+ top: number;
67
+ height: number;
68
+ }
69
+
70
+ function groupIntoRows(
71
+ boxes: Array<{ width: number; height: number; top: number; left: number }>
72
+ ): RowData[] {
73
+ if (boxes.length === 0) return [];
74
+
75
+ const rows: RowData[] = [];
76
+ let currentRowTop = boxes[0].top;
77
+ let currentRowStart = 0;
78
+ let currentRowHeight = boxes[0].height;
79
+
80
+ for (let i = 1; i < boxes.length; i++) {
81
+ const box = boxes[i];
82
+ // New row if top position changes significantly
83
+ if (Math.abs(box.top - currentRowTop) > 1) {
84
+ rows.push({
85
+ startIndex: currentRowStart,
86
+ endIndex: i,
87
+ top: currentRowTop,
88
+ height: currentRowHeight,
89
+ });
90
+ currentRowStart = i;
91
+ currentRowTop = box.top;
92
+ currentRowHeight = box.height;
93
+ } else {
94
+ // Same row - take max height in case of slight variations
95
+ currentRowHeight = Math.max(currentRowHeight, box.height);
96
+ }
97
+ }
98
+
99
+ // Push final row
100
+ rows.push({
101
+ startIndex: currentRowStart,
102
+ endIndex: boxes.length,
103
+ top: currentRowTop,
104
+ height: currentRowHeight,
105
+ });
106
+
107
+ return rows;
108
+ }
109
+
110
+ export function ImageGrid({ samples, onLoadMore, hasMore }: ImageGridProps) {
111
+ const containerRef = useRef<HTMLDivElement>(null);
112
+ const [containerWidth, setContainerWidth] = useState(0);
113
+
114
+ const {
115
+ selectedIds,
116
+ isLassoSelection,
117
+ selectionSource,
118
+ toggleSelection,
119
+ addToSelection,
120
+ setHoveredId,
121
+ hoveredId,
122
+ labelFilter,
123
+ } = useStore();
124
+
125
+ // Track container width for layout computation
126
+ useEffect(() => {
127
+ const container = containerRef.current;
128
+ if (!container) return;
129
+
130
+ const updateWidth = () => {
131
+ const width = container.clientWidth;
132
+ if (width > 0 && width !== containerWidth) {
133
+ setContainerWidth(width);
134
+ }
135
+ };
136
+
137
+ updateWidth();
138
+
139
+ const resizeObserver = new ResizeObserver(() => {
140
+ requestAnimationFrame(updateWidth);
141
+ });
142
+ resizeObserver.observe(container);
143
+
144
+ return () => resizeObserver.disconnect();
145
+ }, [containerWidth]);
146
+
147
+ // Compute justified layout
148
+ const { boxes, containerHeight } = useMemo(
149
+ () => computeLayout(samples, containerWidth),
150
+ [samples, containerWidth]
151
+ );
152
+
153
+ // Group into rows for virtualization
154
+ const rows = useMemo(() => groupIntoRows(boxes), [boxes]);
155
+
156
+ // Virtualizer for rows
157
+ const virtualizer = useVirtualizer({
158
+ count: rows.length,
159
+ getScrollElement: () => containerRef.current,
160
+ estimateSize: (index) => rows[index]?.height ?? TARGET_ROW_HEIGHT,
161
+ overscan: 3,
162
+ getItemKey: (index) => {
163
+ const row = rows[index];
164
+ if (!row) return `row-${index}`;
165
+ // Create stable key from sample IDs in this row
166
+ const rowSamples = samples.slice(row.startIndex, row.endIndex);
167
+ return rowSamples.map((s) => s.id).join("-") || `row-${index}`;
168
+ },
169
+ });
170
+
171
+ // Load more when scrolling near bottom
172
+ useEffect(() => {
173
+ const container = containerRef.current;
174
+ if (!container || !onLoadMore || !hasMore) return;
175
+
176
+ const handleScroll = () => {
177
+ const { scrollTop, scrollHeight, clientHeight } = container;
178
+ if (scrollHeight - scrollTop - clientHeight < 500) {
179
+ onLoadMore();
180
+ }
181
+ };
182
+
183
+ container.addEventListener("scroll", handleScroll);
184
+ return () => container.removeEventListener("scroll", handleScroll);
185
+ }, [onLoadMore, hasMore]);
186
+
187
+ // Reset scroll on filter change
188
+ useEffect(() => {
189
+ containerRef.current?.scrollTo({ top: 0 });
190
+ }, [labelFilter]);
191
+
192
+ // Scroll to top when scatter selection made
193
+ useEffect(() => {
194
+ if (isLassoSelection) return;
195
+ if (selectionSource !== "scatter") return;
196
+ if (selectedIds.size === 0) return;
197
+
198
+ try {
199
+ virtualizer.scrollToIndex(0, { align: "start" });
200
+ } catch {
201
+ containerRef.current?.scrollTo({ top: 0 });
202
+ }
203
+ }, [isLassoSelection, selectedIds, selectionSource, virtualizer]);
204
+
205
+ // Handle click with selection logic
206
+ const handleClick = useCallback(
207
+ (sample: Sample, event: React.MouseEvent) => {
208
+ if (event.metaKey || event.ctrlKey) {
209
+ toggleSelection(sample.id);
210
+ } else if (event.shiftKey && selectedIds.size > 0) {
211
+ const selectedArray = Array.from(selectedIds);
212
+ const lastSelected = selectedArray[selectedArray.length - 1];
213
+ const lastIndex = samples.findIndex((s) => s.id === lastSelected);
214
+ const currentIndex = samples.findIndex((s) => s.id === sample.id);
215
+
216
+ if (lastIndex !== -1 && currentIndex !== -1) {
217
+ const start = Math.min(lastIndex, currentIndex);
218
+ const end = Math.max(lastIndex, currentIndex);
219
+ const rangeIds = samples.slice(start, end + 1).map((s) => s.id);
220
+ addToSelection(rangeIds);
221
+ }
222
+ } else {
223
+ const newSet = new Set<string>();
224
+ newSet.add(sample.id);
225
+ useStore.getState().setSelectedIds(newSet, "grid");
226
+ }
227
+ },
228
+ [samples, selectedIds, toggleSelection, addToSelection]
229
+ );
230
+
231
+ const virtualRows = virtualizer.getVirtualItems();
232
+
233
+ return (
234
+ <Panel>
235
+ <div className="flex-1 min-h-0 overflow-hidden">
236
+ <div ref={containerRef} className="panel-scroll h-full min-h-0 overflow-auto">
237
+ <div
238
+ style={{
239
+ height: containerHeight || "100%",
240
+ width: "100%",
241
+ position: "relative",
242
+ }}
243
+ >
244
+ {virtualRows.map((virtualRow) => {
245
+ const row = rows[virtualRow.index];
246
+ if (!row) return null;
247
+
248
+ const rowSamples = samples.slice(row.startIndex, row.endIndex);
249
+ const rowBoxes = boxes.slice(row.startIndex, row.endIndex);
250
+
251
+ return (
252
+ <div
253
+ key={virtualRow.key}
254
+ style={{
255
+ position: "absolute",
256
+ top: 0,
257
+ left: 0,
258
+ width: "100%",
259
+ height: row.height,
260
+ transform: `translateY(${row.top}px)`,
261
+ }}
262
+ >
263
+ {rowSamples.map((sample, i) => {
264
+ const box = rowBoxes[i];
265
+ if (!box) return null;
266
+
267
+ const isSelected = isLassoSelection ? true : selectedIds.has(sample.id);
268
+ const isHovered = hoveredId === sample.id;
269
+
270
+ return (
271
+ <div
272
+ key={sample.id}
273
+ style={{
274
+ position: "absolute",
275
+ left: box.left,
276
+ top: 0,
277
+ width: box.width,
278
+ height: box.height,
279
+ }}
280
+ className={`
281
+ overflow-hidden cursor-pointer
282
+ transition-shadow duration-150 ease-out
283
+ ${isSelected ? "ring-2 ring-inset ring-primary" : ""}
284
+ ${isHovered && !isSelected ? "ring-2 ring-inset ring-primary/50" : ""}
285
+ `}
286
+ onClick={(e) => handleClick(sample, e)}
287
+ onMouseEnter={() => setHoveredId(sample.id)}
288
+ onMouseLeave={() => setHoveredId(null)}
289
+ >
290
+ {/* Image container - justified layout sizes tile to preserve aspect ratio */}
291
+ {/* Future: overlays (segmentations, bboxes) will be absolutely positioned here */}
292
+ {sample.thumbnail ? (
293
+ // eslint-disable-next-line @next/next/no-img-element
294
+ <img
295
+ src={`data:image/jpeg;base64,${sample.thumbnail}`}
296
+ alt={sample.filename}
297
+ className="w-full h-full object-cover"
298
+ loading="lazy"
299
+ />
300
+ ) : (
301
+ <div className="w-full h-full bg-muted flex items-center justify-center">
302
+ <span className="text-muted-foreground text-xs">No image</span>
303
+ </div>
304
+ )}
305
+
306
+ {/* Label badge */}
307
+ {sample.label && (
308
+ <div className="absolute bottom-0.5 left-0.5 right-0.5">
309
+ <span
310
+ className="inline-block px-1 py-0.5 text-[10px] leading-tight truncate max-w-full"
311
+ style={{
312
+ backgroundColor: "rgba(0,0,0,0.7)",
313
+ color: "#fff",
314
+ }}
315
+ >
316
+ {sample.label}
317
+ </span>
318
+ </div>
319
+ )}
320
+
321
+ {/* Selection indicator */}
322
+ {isSelected && (
323
+ <div className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
324
+ <CheckIcon />
325
+ </div>
326
+ )}
327
+ </div>
328
+ );
329
+ })}
330
+ </div>
331
+ );
332
+ })}
333
+ </div>
334
+ </div>
335
+ </div>
336
+ </Panel>
337
+ );
338
+ }
frontend/src/components/Panel.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { ReactNode } from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ interface PanelProps {
7
+ children: ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * Base panel container with consistent Rerun-style appearance.
13
+ * No borders or rounded corners - panels should be flush against each other.
14
+ */
15
+ export function Panel({ children, className }: PanelProps) {
16
+ return (
17
+ <div className={cn(
18
+ "flex flex-col h-full bg-card overflow-hidden",
19
+ className
20
+ )}>
21
+ {children}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ interface PanelFooterProps {
27
+ children: ReactNode;
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * Panel footer for keyboard shortcuts/hints.
33
+ */
34
+ export function PanelFooter({ children, className }: PanelFooterProps) {
35
+ return (
36
+ <div className={cn(
37
+ "px-3 py-1 text-[11px] text-muted-foreground/70 border-t border-border bg-card font-mono",
38
+ className
39
+ )}>
40
+ {children}
41
+ </div>
42
+ );
43
+ }
frontend/src/components/PanelHeader.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { ReactNode } from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ interface PanelHeaderProps {
7
+ icon?: ReactNode;
8
+ title: string;
9
+ subtitle?: string;
10
+ children?: ReactNode; // Toolbar actions slot
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Rerun-style panel header with icon, title, and optional toolbar.
16
+ *
17
+ * Design tokens (from Rerun):
18
+ * - Title bar height: 24px
19
+ * - Icon size: 14px (3.5 tailwind units)
20
+ * - Icon-to-text gap: 4px (gap-1)
21
+ * - Font size: 12px with -0.15px tracking
22
+ * - Section header font: 11px uppercase
23
+ */
24
+ export function PanelHeader({ icon, title, subtitle, children, className }: PanelHeaderProps) {
25
+ return (
26
+ <div className={cn(
27
+ // 24px height matches Rerun's title_bar_height()
28
+ "h-6 min-h-[24px] flex items-center justify-between px-3 border-b border-border bg-secondary select-none",
29
+ className
30
+ )}>
31
+ <div className="flex items-center gap-1 min-w-0">
32
+ {icon && (
33
+ // 14px icon (3.5 tailwind units) with 4px gap to text
34
+ <span className="flex-shrink-0 w-3.5 h-3.5 text-muted-foreground">{icon}</span>
35
+ )}
36
+ {/* 12px font, medium weight, tight tracking */}
37
+ <span className="text-[12px] leading-[16px] font-medium tracking-[-0.15px] text-foreground truncate">{title}</span>
38
+ {subtitle && (
39
+ <span className="text-[11px] leading-4 text-muted-foreground truncate">{subtitle}</span>
40
+ )}
41
+ </div>
42
+ {children && (
43
+ <div className="flex items-center gap-1">{children}</div>
44
+ )}
45
+ </div>
46
+ );
47
+ }
frontend/src/components/PlaceholderPanel.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { HyperViewLogo } from "./icons";
5
+ import { Panel } from "./Panel";
6
+ import { cn } from "@/lib/utils";
7
+ import { X } from "lucide-react";
8
+
9
+ interface PlaceholderPanelProps {
10
+ className?: string;
11
+ onClose?: () => void;
12
+ }
13
+
14
+ /**
15
+ * Empty placeholder panel with centered HyperView logo and a close button.
16
+ * Used for right and bottom zones that are reserved for future features.
17
+ * The close button is always visible in the top-right corner of the panel content.
18
+ */
19
+ export function PlaceholderPanel({ className, onClose }: PlaceholderPanelProps) {
20
+ return (
21
+ <Panel className={cn("h-full w-full relative", className)}>
22
+ {/* Close button always visible in top right */}
23
+ {onClose && (
24
+ <button
25
+ onClick={onClose}
26
+ className="absolute top-3 right-3 p-1.5 rounded-md text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/60 transition-colors z-10"
27
+ aria-label="Close panel"
28
+ >
29
+ <X className="h-4 w-4" />
30
+ </button>
31
+ )}
32
+ <div className="flex-1 flex items-center justify-center">
33
+ <div className="text-muted-foreground/20">
34
+ <HyperViewLogo className="w-12 h-12" />
35
+ </div>
36
+ </div>
37
+ </Panel>
38
+ );
39
+ }
frontend/src/components/ScatterPanel.tsx ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { useStore } from "@/store/useStore";
5
+ import { Panel } from "./Panel";
6
+ import { useHyperScatter } from "./useHyperScatter";
7
+ import { useLabelLegend } from "./useLabelLegend";
8
+ import type { Geometry } from "@/types";
9
+ import { findLayoutByGeometry, listAvailableGeometries } from "@/lib/layouts";
10
+ import { fetchEmbeddings } from "@/lib/api";
11
+
12
+ interface ScatterPanelProps {
13
+ className?: string;
14
+ layoutKey?: string;
15
+ geometry?: Geometry;
16
+ }
17
+
18
+ export function ScatterPanel({
19
+ className = "",
20
+ layoutKey,
21
+ geometry,
22
+ }: ScatterPanelProps) {
23
+ const {
24
+ datasetInfo,
25
+ embeddingsByLayoutKey,
26
+ setEmbeddingsForLayout,
27
+ selectedIds,
28
+ setSelectedIds,
29
+ beginLassoSelection,
30
+ hoveredId,
31
+ setHoveredId,
32
+ setActiveLayoutKey,
33
+ labelFilter,
34
+ } = useStore();
35
+
36
+ const [localGeometry, setLocalGeometry] = useState<Geometry>("euclidean");
37
+
38
+ // Check which geometries are available
39
+ const availableGeometries = useMemo(() => {
40
+ return listAvailableGeometries(datasetInfo?.layouts ?? []);
41
+ }, [datasetInfo?.layouts]);
42
+
43
+ useEffect(() => {
44
+ if (geometry) return;
45
+ if (availableGeometries.length === 0) return;
46
+ if (!availableGeometries.includes(localGeometry)) {
47
+ setLocalGeometry(availableGeometries[0]);
48
+ }
49
+ }, [availableGeometries, geometry, localGeometry]);
50
+
51
+ const resolvedGeometry = geometry ?? localGeometry;
52
+
53
+ const resolvedLayoutKey = useMemo(() => {
54
+ if (!datasetInfo) return layoutKey ?? null;
55
+
56
+ if (layoutKey) {
57
+ const exists = datasetInfo.layouts.some((layout) => layout.layout_key === layoutKey);
58
+ if (exists) return layoutKey;
59
+ }
60
+
61
+ const layout = findLayoutByGeometry(datasetInfo.layouts, resolvedGeometry);
62
+ return layout?.layout_key ?? datasetInfo.layouts[0]?.layout_key ?? null;
63
+ }, [datasetInfo, layoutKey, resolvedGeometry]);
64
+
65
+ const embeddings = resolvedLayoutKey ? embeddingsByLayoutKey[resolvedLayoutKey] ?? null : null;
66
+
67
+ useEffect(() => {
68
+ if (!resolvedLayoutKey) return;
69
+ setActiveLayoutKey(resolvedLayoutKey);
70
+ }, [resolvedLayoutKey, setActiveLayoutKey]);
71
+
72
+ useEffect(() => {
73
+ if (!resolvedLayoutKey) return;
74
+ if (embeddingsByLayoutKey[resolvedLayoutKey]) return;
75
+
76
+ let cancelled = false;
77
+
78
+ fetchEmbeddings(resolvedLayoutKey)
79
+ .then((data) => {
80
+ if (cancelled) return;
81
+ setEmbeddingsForLayout(resolvedLayoutKey, data);
82
+ })
83
+ .catch((err) => {
84
+ if (cancelled) return;
85
+ console.error("Failed to load embeddings:", err);
86
+ })
87
+
88
+ return () => {
89
+ cancelled = true;
90
+ };
91
+ }, [embeddingsByLayoutKey, resolvedLayoutKey, setEmbeddingsForLayout]);
92
+
93
+ const { labelsInfo } = useLabelLegend({ datasetInfo, embeddings, labelFilter });
94
+
95
+ const {
96
+ canvasRef,
97
+ overlayCanvasRef,
98
+ containerRef,
99
+ handlePointerDown,
100
+ handlePointerMove,
101
+ handlePointerUp,
102
+ handlePointerLeave,
103
+ handleDoubleClick,
104
+ rendererError,
105
+ } = useHyperScatter({
106
+ embeddings,
107
+ labelsInfo,
108
+ selectedIds,
109
+ hoveredId,
110
+ setSelectedIds,
111
+ beginLassoSelection,
112
+ setHoveredId,
113
+ hoverEnabled: !labelFilter,
114
+ });
115
+
116
+ const focusLayout = useCallback(() => {
117
+ if (!resolvedLayoutKey) return;
118
+ setActiveLayoutKey(resolvedLayoutKey);
119
+ }, [resolvedLayoutKey, setActiveLayoutKey]);
120
+
121
+ const loadingLabel = resolvedLayoutKey
122
+ ? "Loading embeddings..."
123
+ : "No embeddings layout available";
124
+
125
+ return (
126
+ <Panel className={className}>
127
+ {/* Main content area - min-h-0 prevents flex overflow */}
128
+ <div className="flex-1 flex min-h-0">
129
+ {/* Canvas container */}
130
+ <div ref={containerRef} className="flex-1 relative min-w-0">
131
+ <canvas
132
+ ref={canvasRef}
133
+ className="absolute inset-0"
134
+ style={{ zIndex: 1 }}
135
+ onPointerDown={(e) => {
136
+ focusLayout();
137
+ handlePointerDown(e);
138
+ }}
139
+ onPointerMove={handlePointerMove}
140
+ onPointerUp={handlePointerUp}
141
+ onPointerCancel={handlePointerUp}
142
+ onPointerLeave={handlePointerLeave}
143
+ onDoubleClick={handleDoubleClick}
144
+ onPointerEnter={focusLayout}
145
+ />
146
+
147
+ {/* Lasso overlay (screen-space) */}
148
+ <canvas
149
+ ref={overlayCanvasRef}
150
+ className="absolute inset-0 pointer-events-none"
151
+ style={{ zIndex: 20 }}
152
+ />
153
+
154
+ {/* Loading overlay */}
155
+ {rendererError ? (
156
+ <div className="absolute inset-0 flex items-center justify-center bg-card/85 z-10 p-6">
157
+ <div className="max-w-md text-center">
158
+ <div className="text-sm font-semibold text-foreground mb-2">Browser not supported</div>
159
+ <div className="text-sm text-muted-foreground">{rendererError}</div>
160
+ </div>
161
+ </div>
162
+ ) : (
163
+ !embeddings && (
164
+ <div className="absolute inset-0 flex items-center justify-center bg-card/80 z-10">
165
+ <div className="text-muted-foreground">{loadingLabel}</div>
166
+ </div>
167
+ )
168
+ )}
169
+ </div>
170
+
171
+ </div>
172
+ </Panel>
173
+ );
174
+ }
frontend/src/components/icons.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /**
4
+ * Shared icons for HyperView UI.
5
+ * Using inline SVGs for simplicity (no extra icon library dependency).
6
+ */
7
+
8
+ export const GridIcon = () => (
9
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-4 h-4">
10
+ <rect x="3" y="3" width="7" height="7" />
11
+ <rect x="14" y="3" width="7" height="7" />
12
+ <rect x="3" y="14" width="7" height="7" />
13
+ <rect x="14" y="14" width="7" height="7" />
14
+ </svg>
15
+ );
16
+
17
+ export const ScatterIcon = () => (
18
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-4 h-4">
19
+ <circle cx="8" cy="8" r="2" />
20
+ <circle cx="16" cy="16" r="2" />
21
+ <circle cx="18" cy="8" r="2" />
22
+ <circle cx="6" cy="16" r="2" />
23
+ <circle cx="12" cy="12" r="2" />
24
+ </svg>
25
+ );
26
+
27
+ export const HyperViewLogo = ({ className = "w-5 h-5" }: { className?: string }) => (
28
+ <svg viewBox="0 0 24 24" fill="none" className={className}>
29
+ <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" />
30
+ <circle cx="12" cy="12" r="6" stroke="currentColor" strokeWidth="1.5" opacity="0.6" />
31
+ <circle cx="12" cy="12" r="2" fill="currentColor" />
32
+ </svg>
33
+ );
34
+
35
+ export const CheckIcon = () => (
36
+ <svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
37
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
38
+ </svg>
39
+ );
40
+
41
+ /** Euclidean geometry icon - flat grid */
42
+ export const EuclideanIcon = () => (
43
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" className="w-3.5 h-3.5">
44
+ <rect x="2" y="2" width="12" height="12" rx="1" />
45
+ <line x1="2" y1="8" x2="14" y2="8" />
46
+ <line x1="8" y1="2" x2="8" y2="14" />
47
+ </svg>
48
+ );
49
+
50
+ /** Poincaré disk icon - hyperbolic geometry */
51
+ export const PoincareIcon = () => (
52
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" className="w-3.5 h-3.5">
53
+ <circle cx="8" cy="8" r="6" />
54
+ <ellipse cx="8" cy="8" rx="3" ry="5.5" />
55
+ <ellipse cx="8" cy="8" rx="5.5" ry="3" />
56
+ </svg>
57
+ );
58
+
59
+ /** Spherical geometry icon - for future use */
60
+ export const SphericalIcon = () => (
61
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" className="w-3.5 h-3.5">
62
+ <circle cx="8" cy="8" r="6" />
63
+ <ellipse cx="8" cy="8" rx="6" ry="2.5" />
64
+ <path d="M8 2 Q12 8 8 14" />
65
+ </svg>
66
+ );
67
+
68
+ /** Discord icon - official simplified logo */
69
+ export const DiscordIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
70
+ <svg viewBox="0 0 24 24" fill="currentColor" className={className}>
71
+ <path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.09.09 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.09 16.09 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02zM8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12zm6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12z"/>
72
+ </svg>
73
+ );
frontend/src/components/index.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export { ImageGrid } from "./ImageGrid";
2
+ export { ScatterPanel } from "./ScatterPanel";
3
+ export { DockviewWorkspace } from "./DockviewWorkspace";
4
+ export { Header } from "./Header";
5
+ export { Panel, PanelFooter } from "./Panel";
6
+ export { PanelHeader } from "./PanelHeader";
7
+ export { ExplorerPanel } from "./ExplorerPanel";
8
+ export { PlaceholderPanel } from "./PlaceholderPanel";
9
+ export * from "./icons";
frontend/src/components/ui/button.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16
+ outline:
17
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20
+ ghost: "hover:bg-accent hover:text-accent-foreground",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2",
25
+ sm: "h-8 rounded-md px-3 text-xs",
26
+ lg: "h-10 rounded-md px-8",
27
+ icon: "h-9 w-9",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "default",
33
+ },
34
+ }
35
+ )
36
+
37
+ export interface ButtonProps
38
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
39
+ VariantProps<typeof buttonVariants> {
40
+ asChild?: boolean
41
+ }
42
+
43
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
45
+ const Comp = asChild ? Slot : "button"
46
+ return (
47
+ <Comp
48
+ className={cn(buttonVariants({ variant, size, className }))}
49
+ ref={ref}
50
+ {...props}
51
+ />
52
+ )
53
+ }
54
+ )
55
+ Button.displayName = "Button"
56
+
57
+ export { Button, buttonVariants }
frontend/src/components/ui/collapsible.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
4
+
5
+ const Collapsible = CollapsiblePrimitive.Root
6
+
7
+ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
8
+
9
+ const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
10
+
11
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent }
frontend/src/components/ui/command.tsx ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { type DialogProps } from "@radix-ui/react-dialog"
5
+ import { Command as CommandPrimitive } from "cmdk"
6
+ import { Search } from "lucide-react"
7
+
8
+ import { cn } from "@/lib/utils"
9
+ import { Dialog, DialogContent } from "@/components/ui/dialog"
10
+
11
+ const Command = React.forwardRef<
12
+ React.ElementRef<typeof CommandPrimitive>,
13
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
14
+ >(({ className, ...props }, ref) => (
15
+ <CommandPrimitive
16
+ ref={ref}
17
+ className={cn(
18
+ "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
19
+ className
20
+ )}
21
+ {...props}
22
+ />
23
+ ))
24
+ Command.displayName = CommandPrimitive.displayName
25
+
26
+ const CommandDialog = ({ children, ...props }: DialogProps) => {
27
+ return (
28
+ <Dialog {...props}>
29
+ <DialogContent className="overflow-hidden p-0">
30
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
31
+ {children}
32
+ </Command>
33
+ </DialogContent>
34
+ </Dialog>
35
+ )
36
+ }
37
+
38
+ const CommandInput = React.forwardRef<
39
+ React.ElementRef<typeof CommandPrimitive.Input>,
40
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
41
+ >(({ className, ...props }, ref) => (
42
+ <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
43
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
44
+ <CommandPrimitive.Input
45
+ ref={ref}
46
+ className={cn(
47
+ "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
48
+ className
49
+ )}
50
+ {...props}
51
+ />
52
+ </div>
53
+ ))
54
+
55
+ CommandInput.displayName = CommandPrimitive.Input.displayName
56
+
57
+ const CommandList = React.forwardRef<
58
+ React.ElementRef<typeof CommandPrimitive.List>,
59
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
60
+ >(({ className, ...props }, ref) => (
61
+ <CommandPrimitive.List
62
+ ref={ref}
63
+ className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
64
+ {...props}
65
+ />
66
+ ))
67
+
68
+ CommandList.displayName = CommandPrimitive.List.displayName
69
+
70
+ const CommandEmpty = React.forwardRef<
71
+ React.ElementRef<typeof CommandPrimitive.Empty>,
72
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
73
+ >((props, ref) => (
74
+ <CommandPrimitive.Empty
75
+ ref={ref}
76
+ className="py-6 text-center text-sm"
77
+ {...props}
78
+ />
79
+ ))
80
+
81
+ CommandEmpty.displayName = CommandPrimitive.Empty.displayName
82
+
83
+ const CommandGroup = React.forwardRef<
84
+ React.ElementRef<typeof CommandPrimitive.Group>,
85
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
86
+ >(({ className, ...props }, ref) => (
87
+ <CommandPrimitive.Group
88
+ ref={ref}
89
+ className={cn(
90
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
91
+ className
92
+ )}
93
+ {...props}
94
+ />
95
+ ))
96
+
97
+ CommandGroup.displayName = CommandPrimitive.Group.displayName
98
+
99
+ const CommandSeparator = React.forwardRef<
100
+ React.ElementRef<typeof CommandPrimitive.Separator>,
101
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
102
+ >(({ className, ...props }, ref) => (
103
+ <CommandPrimitive.Separator
104
+ ref={ref}
105
+ className={cn("-mx-1 h-px bg-border", className)}
106
+ {...props}
107
+ />
108
+ ))
109
+ CommandSeparator.displayName = CommandPrimitive.Separator.displayName
110
+
111
+ const CommandItem = React.forwardRef<
112
+ React.ElementRef<typeof CommandPrimitive.Item>,
113
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
114
+ >(({ className, ...props }, ref) => (
115
+ <CommandPrimitive.Item
116
+ ref={ref}
117
+ className={cn(
118
+ "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
119
+ className
120
+ )}
121
+ {...props}
122
+ />
123
+ ))
124
+
125
+ CommandItem.displayName = CommandPrimitive.Item.displayName
126
+
127
+ const CommandShortcut = ({
128
+ className,
129
+ ...props
130
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
131
+ return (
132
+ <span
133
+ className={cn(
134
+ "ml-auto text-xs tracking-widest text-muted-foreground",
135
+ className
136
+ )}
137
+ {...props}
138
+ />
139
+ )
140
+ }
141
+ CommandShortcut.displayName = "CommandShortcut"
142
+
143
+ export {
144
+ Command,
145
+ CommandDialog,
146
+ CommandInput,
147
+ CommandList,
148
+ CommandEmpty,
149
+ CommandGroup,
150
+ CommandItem,
151
+ CommandShortcut,
152
+ CommandSeparator,
153
+ }
frontend/src/components/ui/dialog.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
5
+ import { X } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const Dialog = DialogPrimitive.Root
10
+
11
+ const DialogTrigger = DialogPrimitive.Trigger
12
+
13
+ const DialogPortal = DialogPrimitive.Portal
14
+
15
+ const DialogClose = DialogPrimitive.Close
16
+
17
+ const DialogOverlay = React.forwardRef<
18
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
19
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20
+ >(({ className, ...props }, ref) => (
21
+ <DialogPrimitive.Overlay
22
+ ref={ref}
23
+ className={cn(
24
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ className
26
+ )}
27
+ {...props}
28
+ />
29
+ ))
30
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31
+
32
+ const DialogContent = React.forwardRef<
33
+ React.ElementRef<typeof DialogPrimitive.Content>,
34
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35
+ >(({ className, children, ...props }, ref) => (
36
+ <DialogPortal>
37
+ <DialogOverlay />
38
+ <DialogPrimitive.Content
39
+ ref={ref}
40
+ className={cn(
41
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
48
+ <X className="h-4 w-4" />
49
+ <span className="sr-only">Close</span>
50
+ </DialogPrimitive.Close>
51
+ </DialogPrimitive.Content>
52
+ </DialogPortal>
53
+ ))
54
+ DialogContent.displayName = DialogPrimitive.Content.displayName
55
+
56
+ const DialogHeader = ({
57
+ className,
58
+ ...props
59
+ }: React.HTMLAttributes<HTMLDivElement>) => (
60
+ <div
61
+ className={cn(
62
+ "flex flex-col space-y-1.5 text-center sm:text-left",
63
+ className
64
+ )}
65
+ {...props}
66
+ />
67
+ )
68
+ DialogHeader.displayName = "DialogHeader"
69
+
70
+ const DialogFooter = ({
71
+ className,
72
+ ...props
73
+ }: React.HTMLAttributes<HTMLDivElement>) => (
74
+ <div
75
+ className={cn(
76
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
77
+ className
78
+ )}
79
+ {...props}
80
+ />
81
+ )
82
+ DialogFooter.displayName = "DialogFooter"
83
+
84
+ const DialogTitle = React.forwardRef<
85
+ React.ElementRef<typeof DialogPrimitive.Title>,
86
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
87
+ >(({ className, ...props }, ref) => (
88
+ <DialogPrimitive.Title
89
+ ref={ref}
90
+ className={cn(
91
+ "text-lg font-semibold leading-none tracking-tight",
92
+ className
93
+ )}
94
+ {...props}
95
+ />
96
+ ))
97
+ DialogTitle.displayName = DialogPrimitive.Title.displayName
98
+
99
+ const DialogDescription = React.forwardRef<
100
+ React.ElementRef<typeof DialogPrimitive.Description>,
101
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
102
+ >(({ className, ...props }, ref) => (
103
+ <DialogPrimitive.Description
104
+ ref={ref}
105
+ className={cn("text-sm text-muted-foreground", className)}
106
+ {...props}
107
+ />
108
+ ))
109
+ DialogDescription.displayName = DialogPrimitive.Description.displayName
110
+
111
+ export {
112
+ Dialog,
113
+ DialogPortal,
114
+ DialogOverlay,
115
+ DialogTrigger,
116
+ DialogClose,
117
+ DialogContent,
118
+ DialogHeader,
119
+ DialogFooter,
120
+ DialogTitle,
121
+ DialogDescription,
122
+ }
frontend/src/components/ui/dropdown-menu.tsx ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5
+ import { Check, ChevronRight, Circle } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const DropdownMenu = DropdownMenuPrimitive.Root
10
+
11
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12
+
13
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group
14
+
15
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16
+
17
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub
18
+
19
+ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20
+
21
+ const DropdownMenuSubTrigger = React.forwardRef<
22
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
23
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
24
+ inset?: boolean
25
+ }
26
+ >(({ className, inset, children, ...props }, ref) => (
27
+ <DropdownMenuPrimitive.SubTrigger
28
+ ref={ref}
29
+ className={cn(
30
+ "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
31
+ inset && "pl-8",
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ <ChevronRight className="ml-auto" />
38
+ </DropdownMenuPrimitive.SubTrigger>
39
+ ))
40
+ DropdownMenuSubTrigger.displayName =
41
+ DropdownMenuPrimitive.SubTrigger.displayName
42
+
43
+ const DropdownMenuSubContent = React.forwardRef<
44
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
45
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
46
+ >(({ className, ...props }, ref) => (
47
+ <DropdownMenuPrimitive.SubContent
48
+ ref={ref}
49
+ className={cn(
50
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
51
+ className
52
+ )}
53
+ {...props}
54
+ />
55
+ ))
56
+ DropdownMenuSubContent.displayName =
57
+ DropdownMenuPrimitive.SubContent.displayName
58
+
59
+ const DropdownMenuContent = React.forwardRef<
60
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
61
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
62
+ >(({ className, sideOffset = 4, ...props }, ref) => (
63
+ <DropdownMenuPrimitive.Portal>
64
+ <DropdownMenuPrimitive.Content
65
+ ref={ref}
66
+ sideOffset={sideOffset}
67
+ className={cn(
68
+ "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
69
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
70
+ className
71
+ )}
72
+ {...props}
73
+ />
74
+ </DropdownMenuPrimitive.Portal>
75
+ ))
76
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
77
+
78
+ const DropdownMenuItem = React.forwardRef<
79
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
80
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
81
+ inset?: boolean
82
+ }
83
+ >(({ className, inset, ...props }, ref) => (
84
+ <DropdownMenuPrimitive.Item
85
+ ref={ref}
86
+ className={cn(
87
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
88
+ inset && "pl-8",
89
+ className
90
+ )}
91
+ {...props}
92
+ />
93
+ ))
94
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
95
+
96
+ const DropdownMenuCheckboxItem = React.forwardRef<
97
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
98
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
99
+ >(({ className, children, checked, ...props }, ref) => (
100
+ <DropdownMenuPrimitive.CheckboxItem
101
+ ref={ref}
102
+ className={cn(
103
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
104
+ className
105
+ )}
106
+ checked={checked}
107
+ {...props}
108
+ >
109
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
110
+ <DropdownMenuPrimitive.ItemIndicator>
111
+ <Check className="h-4 w-4" />
112
+ </DropdownMenuPrimitive.ItemIndicator>
113
+ </span>
114
+ {children}
115
+ </DropdownMenuPrimitive.CheckboxItem>
116
+ ))
117
+ DropdownMenuCheckboxItem.displayName =
118
+ DropdownMenuPrimitive.CheckboxItem.displayName
119
+
120
+ const DropdownMenuRadioItem = React.forwardRef<
121
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
122
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
123
+ >(({ className, children, ...props }, ref) => (
124
+ <DropdownMenuPrimitive.RadioItem
125
+ ref={ref}
126
+ className={cn(
127
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
128
+ className
129
+ )}
130
+ {...props}
131
+ >
132
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
133
+ <DropdownMenuPrimitive.ItemIndicator>
134
+ <Circle className="h-2 w-2 fill-current" />
135
+ </DropdownMenuPrimitive.ItemIndicator>
136
+ </span>
137
+ {children}
138
+ </DropdownMenuPrimitive.RadioItem>
139
+ ))
140
+ DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
141
+
142
+ const DropdownMenuLabel = React.forwardRef<
143
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
144
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
145
+ inset?: boolean
146
+ }
147
+ >(({ className, inset, ...props }, ref) => (
148
+ <DropdownMenuPrimitive.Label
149
+ ref={ref}
150
+ className={cn(
151
+ "px-2 py-1.5 text-sm font-semibold",
152
+ inset && "pl-8",
153
+ className
154
+ )}
155
+ {...props}
156
+ />
157
+ ))
158
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
159
+
160
+ const DropdownMenuSeparator = React.forwardRef<
161
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
162
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
163
+ >(({ className, ...props }, ref) => (
164
+ <DropdownMenuPrimitive.Separator
165
+ ref={ref}
166
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
167
+ {...props}
168
+ />
169
+ ))
170
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
171
+
172
+ const DropdownMenuShortcut = ({
173
+ className,
174
+ ...props
175
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
176
+ return (
177
+ <span
178
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
179
+ {...props}
180
+ />
181
+ )
182
+ }
183
+ DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
184
+
185
+ export {
186
+ DropdownMenu,
187
+ DropdownMenuTrigger,
188
+ DropdownMenuContent,
189
+ DropdownMenuItem,
190
+ DropdownMenuCheckboxItem,
191
+ DropdownMenuRadioItem,
192
+ DropdownMenuLabel,
193
+ DropdownMenuSeparator,
194
+ DropdownMenuShortcut,
195
+ DropdownMenuGroup,
196
+ DropdownMenuPortal,
197
+ DropdownMenuSub,
198
+ DropdownMenuSubContent,
199
+ DropdownMenuSubTrigger,
200
+ DropdownMenuRadioGroup,
201
+ }
frontend/src/components/ui/popover.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as PopoverPrimitive from "@radix-ui/react-popover"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const Popover = PopoverPrimitive.Root
9
+
10
+ const PopoverTrigger = PopoverPrimitive.Trigger
11
+
12
+ const PopoverAnchor = PopoverPrimitive.Anchor
13
+
14
+ const PopoverContent = React.forwardRef<
15
+ React.ElementRef<typeof PopoverPrimitive.Content>,
16
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
17
+ >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
18
+ <PopoverPrimitive.Portal>
19
+ <PopoverPrimitive.Content
20
+ ref={ref}
21
+ align={align}
22
+ sideOffset={sideOffset}
23
+ className={cn(
24
+ "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
25
+ className
26
+ )}
27
+ {...props}
28
+ />
29
+ </PopoverPrimitive.Portal>
30
+ ))
31
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName
32
+
33
+ export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
frontend/src/components/ui/radio-group.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5
+ import { Circle } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const RadioGroup = React.forwardRef<
10
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
11
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
12
+ >(({ className, ...props }, ref) => {
13
+ return (
14
+ <RadioGroupPrimitive.Root
15
+ className={cn("grid gap-2", className)}
16
+ {...props}
17
+ ref={ref}
18
+ />
19
+ )
20
+ })
21
+ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22
+
23
+ const RadioGroupItem = React.forwardRef<
24
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
25
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
26
+ >(({ className, ...props }, ref) => {
27
+ return (
28
+ <RadioGroupPrimitive.Item
29
+ ref={ref}
30
+ className={cn(
31
+ "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
37
+ <Circle className="h-3.5 w-3.5 fill-primary" />
38
+ </RadioGroupPrimitive.Indicator>
39
+ </RadioGroupPrimitive.Item>
40
+ )
41
+ })
42
+ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43
+
44
+ export { RadioGroup, RadioGroupItem }
frontend/src/components/ui/scroll-area.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const ScrollArea = React.forwardRef<
9
+ React.ElementRef<typeof ScrollAreaPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
11
+ >(({ className, children, ...props }, ref) => (
12
+ <ScrollAreaPrimitive.Root
13
+ ref={ref}
14
+ className={cn("relative overflow-hidden", className)}
15
+ {...props}
16
+ >
17
+ <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
18
+ {children}
19
+ </ScrollAreaPrimitive.Viewport>
20
+ <ScrollBar />
21
+ <ScrollAreaPrimitive.Corner />
22
+ </ScrollAreaPrimitive.Root>
23
+ ))
24
+ ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25
+
26
+ const ScrollBar = React.forwardRef<
27
+ React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
28
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
29
+ >(({ className, orientation = "vertical", ...props }, ref) => (
30
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
31
+ ref={ref}
32
+ orientation={orientation}
33
+ className={cn(
34
+ "flex touch-none select-none transition-colors",
35
+ // Rerun style: 6px bar width, 2px inner/outer margin
36
+ orientation === "vertical" &&
37
+ "h-full w-[10px] border-l border-l-transparent p-[2px]",
38
+ orientation === "horizontal" &&
39
+ "h-[10px] flex-col border-t border-t-transparent p-[2px]",
40
+ className
41
+ )}
42
+ {...props}
43
+ >
44
+ <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-[3px] bg-muted-foreground/25 hover:bg-muted-foreground/40" />
45
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
46
+ ))
47
+ ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
48
+
49
+ export { ScrollArea, ScrollBar }
frontend/src/components/ui/separator.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SeparatorPrimitive from "@radix-ui/react-separator"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const Separator = React.forwardRef<
9
+ React.ElementRef<typeof SeparatorPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
11
+ >(
12
+ (
13
+ { className, orientation = "horizontal", decorative = true, ...props },
14
+ ref
15
+ ) => (
16
+ <SeparatorPrimitive.Root
17
+ ref={ref}
18
+ decorative={decorative}
19
+ orientation={orientation}
20
+ className={cn(
21
+ "shrink-0 bg-border",
22
+ orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
23
+ className
24
+ )}
25
+ {...props}
26
+ />
27
+ )
28
+ )
29
+ Separator.displayName = SeparatorPrimitive.Root.displayName
30
+
31
+ export { Separator }
frontend/src/components/ui/toggle-group.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
5
+ import { type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "@/lib/utils"
8
+ import { toggleVariants } from "@/components/ui/toggle"
9
+
10
+ const ToggleGroupContext = React.createContext<
11
+ VariantProps<typeof toggleVariants>
12
+ >({
13
+ size: "default",
14
+ variant: "default",
15
+ })
16
+
17
+ const ToggleGroup = React.forwardRef<
18
+ React.ElementRef<typeof ToggleGroupPrimitive.Root>,
19
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
20
+ VariantProps<typeof toggleVariants>
21
+ >(({ className, variant, size, children, ...props }, ref) => (
22
+ <ToggleGroupPrimitive.Root
23
+ ref={ref}
24
+ className={cn("flex items-center justify-center gap-1", className)}
25
+ {...props}
26
+ >
27
+ <ToggleGroupContext.Provider value={{ variant, size }}>
28
+ {children}
29
+ </ToggleGroupContext.Provider>
30
+ </ToggleGroupPrimitive.Root>
31
+ ))
32
+
33
+ ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
34
+
35
+ const ToggleGroupItem = React.forwardRef<
36
+ React.ElementRef<typeof ToggleGroupPrimitive.Item>,
37
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
38
+ VariantProps<typeof toggleVariants>
39
+ >(({ className, children, variant, size, ...props }, ref) => {
40
+ const context = React.useContext(ToggleGroupContext)
41
+
42
+ return (
43
+ <ToggleGroupPrimitive.Item
44
+ ref={ref}
45
+ className={cn(
46
+ toggleVariants({
47
+ variant: context.variant || variant,
48
+ size: context.size || size,
49
+ }),
50
+ className
51
+ )}
52
+ {...props}
53
+ >
54
+ {children}
55
+ </ToggleGroupPrimitive.Item>
56
+ )
57
+ })
58
+
59
+ ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
60
+
61
+ export { ToggleGroup, ToggleGroupItem }
frontend/src/components/ui/toggle.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as TogglePrimitive from "@radix-ui/react-toggle"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const toggleVariants = cva(
10
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
11
+ {
12
+ variants: {
13
+ variant: {
14
+ default: "bg-transparent",
15
+ outline:
16
+ "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground data-[state=on]:border-primary",
17
+ },
18
+ size: {
19
+ default: "h-9 px-2 min-w-9",
20
+ sm: "h-8 px-1.5 min-w-8",
21
+ lg: "h-10 px-2.5 min-w-10",
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ variant: "default",
26
+ size: "default",
27
+ },
28
+ }
29
+ )
30
+
31
+ const Toggle = React.forwardRef<
32
+ React.ElementRef<typeof TogglePrimitive.Root>,
33
+ React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
34
+ VariantProps<typeof toggleVariants>
35
+ >(({ className, variant, size, ...props }, ref) => (
36
+ <TogglePrimitive.Root
37
+ ref={ref}
38
+ className={cn(toggleVariants({ variant, size, className }))}
39
+ {...props}
40
+ />
41
+ ))
42
+
43
+ Toggle.displayName = TogglePrimitive.Root.displayName
44
+
45
+ export { Toggle, toggleVariants }
frontend/src/components/ui/tooltip.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const TooltipProvider = TooltipPrimitive.Provider
9
+
10
+ const Tooltip = TooltipPrimitive.Root
11
+
12
+ const TooltipTrigger = TooltipPrimitive.Trigger
13
+
14
+ const TooltipContent = React.forwardRef<
15
+ React.ElementRef<typeof TooltipPrimitive.Content>,
16
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
17
+ >(({ className, sideOffset = 4, ...props }, ref) => (
18
+ <TooltipPrimitive.Portal>
19
+ <TooltipPrimitive.Content
20
+ ref={ref}
21
+ sideOffset={sideOffset}
22
+ className={cn(
23
+ "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
24
+ className
25
+ )}
26
+ {...props}
27
+ />
28
+ </TooltipPrimitive.Portal>
29
+ ))
30
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName
31
+
32
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
frontend/src/components/useHyperScatter.ts ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type React from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+
4
+ import type { EmbeddingsData } from "@/types";
5
+ import type { ScatterLabelsInfo } from "@/lib/labelLegend";
6
+ import type { Dataset, GeometryMode, Modifiers, Renderer } from "hyper-scatter";
7
+
8
+ type HyperScatterModule = typeof import("hyper-scatter");
9
+
10
+ const MAX_LASSO_VERTS = 512;
11
+
12
+ function supportsWebGL2(): boolean {
13
+ try {
14
+ if (typeof document === "undefined") return false;
15
+ const canvas = document.createElement("canvas");
16
+ return !!canvas.getContext("webgl2");
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function capInterleavedXY(points: ArrayLike<number>, maxVerts: number): number[] {
23
+ const n = Math.floor(points.length / 2);
24
+ if (n <= maxVerts) return Array.from(points as ArrayLike<number>);
25
+
26
+ const out = new Array<number>(maxVerts * 2);
27
+ for (let i = 0; i < maxVerts; i++) {
28
+ const src = Math.floor((i * n) / maxVerts);
29
+ out[i * 2] = points[src * 2];
30
+ out[i * 2 + 1] = points[src * 2 + 1];
31
+ }
32
+ return out;
33
+ }
34
+
35
+
36
+ interface UseHyperScatterArgs {
37
+ embeddings: EmbeddingsData | null;
38
+ labelsInfo: ScatterLabelsInfo | null;
39
+ selectedIds: Set<string>;
40
+ hoveredId: string | null;
41
+ setSelectedIds: (ids: Set<string>, source?: "scatter" | "grid") => void;
42
+ beginLassoSelection: (query: { layoutKey: string; polygon: number[] }) => void;
43
+ setHoveredId: (id: string | null) => void;
44
+ hoverEnabled?: boolean;
45
+ }
46
+
47
+ function toModifiers(e: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean }): Modifiers {
48
+ return {
49
+ shift: e.shiftKey,
50
+ ctrl: e.ctrlKey,
51
+ alt: e.altKey,
52
+ meta: e.metaKey,
53
+ };
54
+ }
55
+
56
+ function clearOverlay(canvas: HTMLCanvasElement | null): void {
57
+ if (!canvas) return;
58
+ const ctx = canvas.getContext("2d");
59
+ if (!ctx) return;
60
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
61
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
62
+ }
63
+
64
+ function drawLassoOverlay(canvas: HTMLCanvasElement | null, points: number[]): void {
65
+ if (!canvas) return;
66
+ const ctx = canvas.getContext("2d");
67
+ if (!ctx) return;
68
+
69
+ clearOverlay(canvas);
70
+ if (points.length < 6) return;
71
+
72
+ ctx.save();
73
+ ctx.lineWidth = 2;
74
+ ctx.strokeStyle = "rgba(79,70,229,0.9)"; // indigo-ish
75
+ ctx.fillStyle = "rgba(79,70,229,0.15)";
76
+
77
+ ctx.beginPath();
78
+ ctx.moveTo(points[0], points[1]);
79
+ for (let i = 2; i < points.length; i += 2) {
80
+ ctx.lineTo(points[i], points[i + 1]);
81
+ }
82
+ ctx.closePath();
83
+ ctx.fill();
84
+ ctx.stroke();
85
+ ctx.restore();
86
+ }
87
+
88
+ export function useHyperScatter({
89
+ embeddings,
90
+ labelsInfo,
91
+ selectedIds,
92
+ hoveredId,
93
+ setSelectedIds,
94
+ beginLassoSelection,
95
+ setHoveredId,
96
+ hoverEnabled = true,
97
+ }: UseHyperScatterArgs) {
98
+ const canvasRef = useRef<HTMLCanvasElement>(null);
99
+ const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
100
+ const containerRef = useRef<HTMLDivElement>(null);
101
+
102
+ const rendererRef = useRef<Renderer | null>(null);
103
+
104
+ const [rendererError, setRendererError] = useState<string | null>(null);
105
+
106
+ const rafPendingRef = useRef(false);
107
+
108
+ // Interaction state (refs to avoid rerender churn)
109
+ const isPanningRef = useRef(false);
110
+ const isLassoingRef = useRef(false);
111
+ const pointerDownXRef = useRef(0);
112
+ const pointerDownYRef = useRef(0);
113
+ const lastPointerXRef = useRef(0);
114
+ const lastPointerYRef = useRef(0);
115
+ const lassoPointsRef = useRef<number[]>([]);
116
+ const persistentLassoRef = useRef<number[] | null>(null);
117
+
118
+ const hoveredIndexRef = useRef<number>(-1);
119
+
120
+ const idToIndex = useMemo(() => {
121
+ if (!embeddings) return null;
122
+ const m = new Map<string, number>();
123
+ for (let i = 0; i < embeddings.ids.length; i++) {
124
+ m.set(embeddings.ids[i], i);
125
+ }
126
+ return m;
127
+ }, [embeddings]);
128
+
129
+ const requestRender = useCallback(() => {
130
+ if (rafPendingRef.current) return;
131
+ rafPendingRef.current = true;
132
+
133
+ requestAnimationFrame(() => {
134
+ rafPendingRef.current = false;
135
+ const renderer = rendererRef.current;
136
+ if (!renderer) return;
137
+
138
+ try {
139
+ renderer.render();
140
+ } catch (err) {
141
+ // Avoid an exception storm that would permanently prevent the UI from updating.
142
+ console.error("hyper-scatter renderer.render() failed:", err);
143
+ try {
144
+ renderer.destroy();
145
+ } catch {
146
+ // ignore
147
+ }
148
+ rendererRef.current = null;
149
+ setRendererError(
150
+ "This browser can't render the scatter plot (WebGL2 is required). Please use Chrome/Edge/Firefox."
151
+ );
152
+ clearOverlay(overlayCanvasRef.current);
153
+ return;
154
+ }
155
+
156
+ if (isLassoingRef.current) {
157
+ drawLassoOverlay(overlayCanvasRef.current, lassoPointsRef.current);
158
+ }
159
+ });
160
+ }, []);
161
+
162
+ const getCanvasPos = useCallback((e: { clientX: number; clientY: number }) => {
163
+ const canvas = canvasRef.current;
164
+ if (!canvas) return { x: 0, y: 0 };
165
+ const rect = canvas.getBoundingClientRect();
166
+ return {
167
+ x: e.clientX - rect.left,
168
+ y: e.clientY - rect.top,
169
+ };
170
+ }, []);
171
+
172
+ const redrawOverlay = useCallback(() => {
173
+ if (!overlayCanvasRef.current) return;
174
+ clearOverlay(overlayCanvasRef.current);
175
+ const persistent = persistentLassoRef.current;
176
+ if (persistent && persistent.length >= 6) {
177
+ drawLassoOverlay(overlayCanvasRef.current, persistent);
178
+ }
179
+ }, []);
180
+
181
+ const clearPersistentLasso = useCallback(() => {
182
+ persistentLassoRef.current = null;
183
+ clearOverlay(overlayCanvasRef.current);
184
+ }, []);
185
+
186
+ const stopInteraction = useCallback(() => {
187
+ isPanningRef.current = false;
188
+ isLassoingRef.current = false;
189
+ lassoPointsRef.current = [];
190
+ if (persistentLassoRef.current) {
191
+ redrawOverlay();
192
+ return;
193
+ }
194
+ clearOverlay(overlayCanvasRef.current);
195
+ }, [redrawOverlay]);
196
+
197
+ // Initialize renderer when embeddings change.
198
+ useEffect(() => {
199
+ if (!embeddings || !labelsInfo) return;
200
+ if (!canvasRef.current || !containerRef.current) return;
201
+
202
+ let cancelled = false;
203
+
204
+ const init = async () => {
205
+ // Clear any previous renderer errors when we attempt to re-init.
206
+ setRendererError(null);
207
+
208
+ if (!supportsWebGL2()) {
209
+ setRendererError(
210
+ "This browser doesn't support WebGL2, so the scatter plot can't be displayed. Please use Chrome/Edge/Firefox."
211
+ );
212
+ return;
213
+ }
214
+
215
+ try {
216
+ const viz = (await import("hyper-scatter")) as HyperScatterModule;
217
+ if (cancelled) return;
218
+
219
+ const container = containerRef.current;
220
+ const canvas = canvasRef.current;
221
+ if (!container || !canvas) return;
222
+
223
+ // Destroy existing renderer (if any)
224
+ if (rendererRef.current) {
225
+ rendererRef.current.destroy();
226
+ rendererRef.current = null;
227
+ }
228
+
229
+ const rect = container.getBoundingClientRect();
230
+ const width = Math.floor(rect.width);
231
+ const height = Math.floor(rect.height);
232
+ if (overlayCanvasRef.current) {
233
+ overlayCanvasRef.current.width = Math.max(1, width);
234
+ overlayCanvasRef.current.height = Math.max(1, height);
235
+ overlayCanvasRef.current.style.width = `${width}px`;
236
+ overlayCanvasRef.current.style.height = `${height}px`;
237
+ redrawOverlay();
238
+ }
239
+
240
+ // Use coords from embeddings response directly
241
+ const coords = embeddings.coords;
242
+ const positions = new Float32Array(coords.length * 2);
243
+ for (let i = 0; i < coords.length; i++) {
244
+ positions[i * 2] = coords[i][0];
245
+ positions[i * 2 + 1] = coords[i][1];
246
+ }
247
+
248
+ const geometry = embeddings.geometry as GeometryMode;
249
+ const dataset: Dataset = viz.createDataset(geometry, positions, labelsInfo.categories);
250
+
251
+ const opts = {
252
+ width,
253
+ height,
254
+ devicePixelRatio: window.devicePixelRatio,
255
+ pointRadius: 4,
256
+ colors: labelsInfo.palette,
257
+ backgroundColor: "#161b22", // Match HyperView theme: --card is #161b22
258
+ };
259
+
260
+ const renderer: Renderer =
261
+ geometry === "euclidean" ? new viz.EuclideanWebGLCandidate() : new viz.HyperbolicWebGLCandidate();
262
+
263
+ renderer.init(canvas, opts);
264
+
265
+ renderer.setDataset(dataset);
266
+ rendererRef.current = renderer;
267
+
268
+ // Force a first render to surface WebGL2 context creation failures early.
269
+ try {
270
+ renderer.render();
271
+ } catch (err) {
272
+ console.error("hyper-scatter initial render failed:", err);
273
+ rendererRef.current = null;
274
+ try {
275
+ renderer.destroy();
276
+ } catch {
277
+ // ignore
278
+ }
279
+ setRendererError(
280
+ "This browser can't render the scatter plot (WebGL2 is required). Please use Chrome/Edge/Firefox."
281
+ );
282
+ return;
283
+ }
284
+
285
+ hoveredIndexRef.current = -1;
286
+ renderer.setHovered(-1);
287
+
288
+ requestRender();
289
+ } catch (err) {
290
+ console.error("Failed to initialize hyper-scatter renderer:", err);
291
+ setRendererError(
292
+ "Failed to initialize the scatter renderer in this browser. Please use Chrome/Edge/Firefox."
293
+ );
294
+ }
295
+ };
296
+
297
+ init();
298
+
299
+ return () => {
300
+ cancelled = true;
301
+ stopInteraction();
302
+ if (rendererRef.current) {
303
+ rendererRef.current.destroy();
304
+ rendererRef.current = null;
305
+ }
306
+ };
307
+ }, [embeddings, labelsInfo, redrawOverlay, requestRender, stopInteraction]);
308
+
309
+ // Store -> renderer sync
310
+ useEffect(() => {
311
+ const renderer = rendererRef.current;
312
+ if (!renderer || !embeddings || !idToIndex) return;
313
+
314
+ const indices = new Set<number>();
315
+ for (const id of selectedIds) {
316
+ const idx = idToIndex.get(id);
317
+ if (typeof idx === "number") indices.add(idx);
318
+ }
319
+
320
+ renderer.setSelection(indices);
321
+
322
+ if (!hoverEnabled) {
323
+ renderer.setHovered(-1);
324
+ hoveredIndexRef.current = -1;
325
+ requestRender();
326
+ return;
327
+ }
328
+
329
+ const hoveredIdx = hoveredId ? (idToIndex.get(hoveredId) ?? -1) : -1;
330
+ renderer.setHovered(hoveredIdx);
331
+ hoveredIndexRef.current = hoveredIdx;
332
+
333
+ requestRender();
334
+ }, [embeddings, hoveredId, hoverEnabled, idToIndex, requestRender, selectedIds]);
335
+
336
+ // Resize handling
337
+ useEffect(() => {
338
+ const container = containerRef.current;
339
+ if (!container) return;
340
+
341
+ const resize = () => {
342
+ const rect = container.getBoundingClientRect();
343
+ const width = Math.floor(rect.width);
344
+ const height = Math.floor(rect.height);
345
+ if (!(width > 0) || !(height > 0)) return;
346
+
347
+ if (overlayCanvasRef.current) {
348
+ overlayCanvasRef.current.width = Math.max(1, width);
349
+ overlayCanvasRef.current.height = Math.max(1, height);
350
+ overlayCanvasRef.current.style.width = `${width}px`;
351
+ overlayCanvasRef.current.style.height = `${height}px`;
352
+ redrawOverlay();
353
+ }
354
+
355
+ const renderer = rendererRef.current;
356
+ if (renderer) {
357
+ renderer.resize(width, height);
358
+ requestRender();
359
+ }
360
+ };
361
+
362
+ resize();
363
+
364
+ const ro = new ResizeObserver(resize);
365
+ ro.observe(container);
366
+ return () => ro.disconnect();
367
+ }, [redrawOverlay, requestRender]);
368
+
369
+ // Wheel zoom (native listener so we can set passive:false)
370
+ useEffect(() => {
371
+ const canvas = canvasRef.current;
372
+ if (!canvas) return;
373
+
374
+ const onWheel = (e: WheelEvent) => {
375
+ const renderer = rendererRef.current;
376
+ if (!renderer) return;
377
+ e.preventDefault();
378
+
379
+ const pos = getCanvasPos(e);
380
+ const delta = -e.deltaY / 100;
381
+ renderer.zoom(pos.x, pos.y, delta, toModifiers(e));
382
+ requestRender();
383
+ };
384
+
385
+ canvas.addEventListener("wheel", onWheel, { passive: false });
386
+ return () => canvas.removeEventListener("wheel", onWheel);
387
+ }, [getCanvasPos, requestRender]);
388
+
389
+ // Pointer interactions
390
+ const handlePointerDown = useCallback(
391
+ (e: React.PointerEvent<HTMLCanvasElement>) => {
392
+ const renderer = rendererRef.current;
393
+ if (!renderer) return;
394
+
395
+ // Left button only
396
+ if (typeof e.button === "number" && e.button !== 0) return;
397
+
398
+ const pos = getCanvasPos(e);
399
+ pointerDownXRef.current = pos.x;
400
+ pointerDownYRef.current = pos.y;
401
+ lastPointerXRef.current = pos.x;
402
+ lastPointerYRef.current = pos.y;
403
+
404
+ if (persistentLassoRef.current) {
405
+ clearPersistentLasso();
406
+ }
407
+
408
+ // Shift-drag = lasso, otherwise pan.
409
+ if (e.shiftKey) {
410
+ isLassoingRef.current = true;
411
+ isPanningRef.current = false;
412
+ lassoPointsRef.current = [pos.x, pos.y];
413
+ drawLassoOverlay(overlayCanvasRef.current, lassoPointsRef.current);
414
+ } else {
415
+ isPanningRef.current = true;
416
+ isLassoingRef.current = false;
417
+ }
418
+
419
+ try {
420
+ e.currentTarget.setPointerCapture(e.pointerId);
421
+ } catch {
422
+ // ignore
423
+ }
424
+
425
+ e.preventDefault();
426
+ },
427
+ [clearPersistentLasso, getCanvasPos]
428
+ );
429
+
430
+ const handlePointerMove = useCallback(
431
+ (e: React.PointerEvent<HTMLCanvasElement>) => {
432
+ const renderer = rendererRef.current;
433
+ if (!renderer) return;
434
+
435
+ const pos = getCanvasPos(e);
436
+
437
+ if (isPanningRef.current) {
438
+ const dx = pos.x - lastPointerXRef.current;
439
+ const dy = pos.y - lastPointerYRef.current;
440
+ lastPointerXRef.current = pos.x;
441
+ lastPointerYRef.current = pos.y;
442
+
443
+ renderer.pan(dx, dy, toModifiers(e));
444
+ requestRender();
445
+ return;
446
+ }
447
+
448
+ if (isLassoingRef.current) {
449
+ const pts = lassoPointsRef.current;
450
+ const lastX = pts[pts.length - 2] ?? pos.x;
451
+ const lastY = pts[pts.length - 1] ?? pos.y;
452
+ const ddx = pos.x - lastX;
453
+ const ddy = pos.y - lastY;
454
+ const distSq = ddx * ddx + ddy * ddy;
455
+
456
+ // Sample at ~2px spacing
457
+ if (distSq >= 4) {
458
+ pts.push(pos.x, pos.y);
459
+ drawLassoOverlay(overlayCanvasRef.current, pts);
460
+ }
461
+ return;
462
+ }
463
+
464
+ if (!hoverEnabled) {
465
+ if (hoveredIndexRef.current !== -1) {
466
+ hoveredIndexRef.current = -1;
467
+ renderer.setHovered(-1);
468
+ requestRender();
469
+ }
470
+ return;
471
+ }
472
+
473
+ // Hover
474
+ const hit = renderer.hitTest(pos.x, pos.y);
475
+ const nextIndex = hit ? hit.index : -1;
476
+ if (nextIndex === hoveredIndexRef.current) return;
477
+ hoveredIndexRef.current = nextIndex;
478
+ renderer.setHovered(nextIndex);
479
+
480
+ if (!embeddings) return;
481
+ if (nextIndex >= 0 && nextIndex < embeddings.ids.length) {
482
+ setHoveredId(embeddings.ids[nextIndex]);
483
+ } else {
484
+ setHoveredId(null);
485
+ }
486
+
487
+ requestRender();
488
+ },
489
+ [embeddings, getCanvasPos, hoverEnabled, requestRender, setHoveredId]
490
+ );
491
+
492
+ const handlePointerUp = useCallback(
493
+ async (e: React.PointerEvent<HTMLCanvasElement>) => {
494
+ const renderer = rendererRef.current;
495
+ if (!renderer || !embeddings) {
496
+ stopInteraction();
497
+ return;
498
+ }
499
+
500
+ if (isLassoingRef.current) {
501
+ const pts = lassoPointsRef.current.slice();
502
+ persistentLassoRef.current = pts.length >= 6 ? pts : null;
503
+ stopInteraction();
504
+ redrawOverlay();
505
+
506
+ if (pts.length >= 6) {
507
+ try {
508
+ const polyline = new Float32Array(pts);
509
+ const result = renderer.lassoSelect(polyline);
510
+
511
+ // Enter server-driven lasso mode by sending a data-space polygon.
512
+ // Backend selection runs in the same coordinate system returned by /api/embeddings.
513
+ const dataCoords = result.geometry?.coords;
514
+ if (!dataCoords || dataCoords.length < 6) return;
515
+
516
+ // Clear any existing manual selection highlights immediately.
517
+ renderer.setSelection(new Set());
518
+
519
+ // Cap vertex count to keep request payload + backend runtime bounded.
520
+ const polygon = capInterleavedXY(dataCoords, MAX_LASSO_VERTS);
521
+ if (polygon.length < 6) return;
522
+
523
+ beginLassoSelection({ layoutKey: embeddings.layout_key, polygon });
524
+ } catch (err) {
525
+ console.error("Lasso selection failed:", err);
526
+ }
527
+ }
528
+
529
+ requestRender();
530
+ return;
531
+ }
532
+
533
+ // Click-to-select (scatter -> image grid)
534
+ // Only treat as a click if the pointer didn't move much (otherwise it's a pan).
535
+ const pos = getCanvasPos(e);
536
+ const dx = pos.x - pointerDownXRef.current;
537
+ const dy = pos.y - pointerDownYRef.current;
538
+ const CLICK_MAX_DIST_SQ = 36; // ~6px
539
+ const isClick = dx * dx + dy * dy <= CLICK_MAX_DIST_SQ;
540
+
541
+ if (isClick) {
542
+ const hit = renderer.hitTest(pos.x, pos.y);
543
+ const idx = hit ? hit.index : -1;
544
+
545
+ if (idx >= 0 && idx < embeddings.ids.length) {
546
+ const id = embeddings.ids[idx];
547
+
548
+ if (e.metaKey || e.ctrlKey) {
549
+ const next = new Set(selectedIds);
550
+ if (next.has(id)) next.delete(id);
551
+ else next.add(id);
552
+ setSelectedIds(next, "scatter");
553
+ } else {
554
+ setSelectedIds(new Set([id]), "scatter");
555
+ }
556
+ }
557
+ }
558
+
559
+ stopInteraction();
560
+ requestRender();
561
+ },
562
+ [
563
+ beginLassoSelection,
564
+ embeddings,
565
+ getCanvasPos,
566
+ redrawOverlay,
567
+ requestRender,
568
+ selectedIds,
569
+ setSelectedIds,
570
+ stopInteraction,
571
+ ]
572
+ );
573
+
574
+ const handlePointerLeave = useCallback(
575
+ (_e: React.PointerEvent<HTMLCanvasElement>) => {
576
+ const renderer = rendererRef.current;
577
+ if (renderer) {
578
+ hoveredIndexRef.current = -1;
579
+ setHoveredId(null);
580
+ renderer.setHovered(-1);
581
+ requestRender();
582
+ }
583
+ stopInteraction();
584
+ },
585
+ [requestRender, setHoveredId, stopInteraction]
586
+ );
587
+
588
+ const handleDoubleClick = useCallback(
589
+ (_e: React.MouseEvent<HTMLCanvasElement>) => {
590
+ const renderer = rendererRef.current;
591
+ if (!renderer) return;
592
+ clearPersistentLasso();
593
+ stopInteraction();
594
+
595
+ renderer.setSelection(new Set());
596
+ setSelectedIds(new Set<string>(), "scatter");
597
+
598
+ requestRender();
599
+ },
600
+ [clearPersistentLasso, requestRender, setSelectedIds, stopInteraction]
601
+ );
602
+
603
+ return {
604
+ canvasRef,
605
+ overlayCanvasRef,
606
+ containerRef,
607
+ handlePointerDown,
608
+ handlePointerMove,
609
+ handlePointerUp,
610
+ handlePointerLeave,
611
+ handleDoubleClick,
612
+ rendererError,
613
+ };
614
+ }
frontend/src/components/useLabelLegend.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+
3
+ import type { DatasetInfo, EmbeddingsData } from "@/types";
4
+ import {
5
+ MAX_DISTINCT_LABEL_COLORS,
6
+ buildLabelColorMap,
7
+ buildLabelCounts,
8
+ buildLabelUniverse,
9
+ buildLabelsInfo,
10
+ buildLegendLabels,
11
+ getDistinctLabelCount,
12
+ } from "@/lib/labelLegend";
13
+
14
+ interface UseLabelLegendArgs {
15
+ datasetInfo: DatasetInfo | null;
16
+ embeddings: EmbeddingsData | null;
17
+ labelSearch?: string;
18
+ labelFilter?: string | null;
19
+ }
20
+
21
+ export function useLabelLegend({
22
+ datasetInfo,
23
+ embeddings,
24
+ labelSearch = "",
25
+ labelFilter = null,
26
+ }: UseLabelLegendArgs) {
27
+ const labelCounts = useMemo(() => buildLabelCounts(embeddings), [embeddings]);
28
+
29
+ const labelUniverse = useMemo(
30
+ () => buildLabelUniverse(datasetInfo?.labels ?? [], embeddings?.labels ?? null),
31
+ [datasetInfo?.labels, embeddings?.labels]
32
+ );
33
+
34
+ const distinctLabelCount = useMemo(() => {
35
+ const fromCounts = getDistinctLabelCount(labelCounts);
36
+ if (fromCounts > 0) return fromCounts;
37
+ let n = labelUniverse.length;
38
+ if (labelUniverse.includes("undefined")) n -= 1;
39
+ return n;
40
+ }, [labelCounts, labelUniverse]);
41
+
42
+ const distinctColoringDisabled = distinctLabelCount > MAX_DISTINCT_LABEL_COLORS;
43
+
44
+ const labelsInfo = useMemo(
45
+ () =>
46
+ buildLabelsInfo({
47
+ datasetLabels: datasetInfo?.labels ?? [],
48
+ embeddings,
49
+ distinctColoringDisabled,
50
+ labelFilter,
51
+ }),
52
+ [datasetInfo?.labels, embeddings, distinctColoringDisabled, labelFilter]
53
+ );
54
+
55
+ const labelColorMap = useMemo(
56
+ () =>
57
+ buildLabelColorMap({
58
+ labelsInfo,
59
+ labelUniverse,
60
+ distinctColoringDisabled,
61
+ labelFilter,
62
+ }),
63
+ [labelsInfo, labelUniverse, distinctColoringDisabled, labelFilter]
64
+ );
65
+
66
+ const legendLabels = useMemo(
67
+ () =>
68
+ buildLegendLabels({
69
+ labelUniverse,
70
+ labelCounts,
71
+ query: labelSearch,
72
+ }),
73
+ [labelUniverse, labelCounts, labelSearch]
74
+ );
75
+
76
+ return {
77
+ labelCounts,
78
+ labelUniverse,
79
+ distinctLabelCount,
80
+ distinctColoringDisabled,
81
+ labelsInfo,
82
+ labelColorMap,
83
+ legendLabels,
84
+ };
85
+ }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetInfo, EmbeddingsData, Sample, SamplesResponse } from "@/types";
2
+
3
+ const API_BASE = process.env.NODE_ENV === "development" ? "http://127.0.0.1:6262" : "";
4
+
5
+ export async function fetchDataset(): Promise<DatasetInfo> {
6
+ const res = await fetch(`${API_BASE}/api/dataset`);
7
+ if (!res.ok) {
8
+ throw new Error(`Failed to fetch dataset: ${res.statusText}`);
9
+ }
10
+ return res.json();
11
+ }
12
+
13
+ export async function fetchSamples(
14
+ offset: number = 0,
15
+ limit: number = 100,
16
+ label?: string
17
+ ): Promise<SamplesResponse> {
18
+ const params = new URLSearchParams({
19
+ offset: offset.toString(),
20
+ limit: limit.toString(),
21
+ });
22
+ if (label) {
23
+ params.set("label", label);
24
+ }
25
+
26
+ const res = await fetch(`${API_BASE}/api/samples?${params}`);
27
+ if (!res.ok) {
28
+ throw new Error(`Failed to fetch samples: ${res.statusText}`);
29
+ }
30
+ return res.json();
31
+ }
32
+
33
+ export async function fetchEmbeddings(layoutKey?: string): Promise<EmbeddingsData> {
34
+ const params = new URLSearchParams();
35
+ if (layoutKey) {
36
+ params.set("layout_key", layoutKey);
37
+ }
38
+ const query = params.toString();
39
+ const res = await fetch(`${API_BASE}/api/embeddings${query ? `?${query}` : ""}`);
40
+ if (!res.ok) {
41
+ throw new Error(`Failed to fetch embeddings: ${res.statusText}`);
42
+ }
43
+ return res.json();
44
+ }
45
+
46
+ export async function fetchSample(sampleId: string): Promise<Sample> {
47
+ const res = await fetch(`${API_BASE}/api/samples/${sampleId}`);
48
+ if (!res.ok) {
49
+ throw new Error(`Failed to fetch sample: ${res.statusText}`);
50
+ }
51
+ return res.json();
52
+ }
53
+
54
+ export async function fetchSamplesBatch(sampleIds: string[]): Promise<Sample[]> {
55
+ const res = await fetch(`${API_BASE}/api/samples/batch`, {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ },
60
+ body: JSON.stringify({ sample_ids: sampleIds }),
61
+ });
62
+ if (!res.ok) {
63
+ throw new Error(`Failed to fetch samples batch: ${res.statusText}`);
64
+ }
65
+ const data = await res.json();
66
+ return data.samples;
67
+ }
68
+
69
+ export interface LassoSelectionResponse {
70
+ total: number;
71
+ offset: number;
72
+ limit: number;
73
+ sample_ids: string[];
74
+ samples: Sample[];
75
+ }
76
+
77
+ export async function fetchLassoSelection(args: {
78
+ layoutKey: string;
79
+ polygon: ArrayLike<number>;
80
+ offset?: number;
81
+ limit?: number;
82
+ includeThumbnails?: boolean;
83
+ signal?: AbortSignal;
84
+ }): Promise<LassoSelectionResponse> {
85
+ const res = await fetch(`${API_BASE}/api/selection/lasso`, {
86
+ method: "POST",
87
+ headers: {
88
+ "Content-Type": "application/json",
89
+ },
90
+ body: JSON.stringify({
91
+ layout_key: args.layoutKey,
92
+ polygon: Array.from(args.polygon),
93
+ offset: args.offset ?? 0,
94
+ limit: args.limit ?? 100,
95
+ include_thumbnails: args.includeThumbnails ?? true,
96
+ }),
97
+ signal: args.signal,
98
+ });
99
+ if (!res.ok) {
100
+ throw new Error(`Failed to fetch lasso selection: ${res.statusText}`);
101
+ }
102
+ return res.json();
103
+ }
frontend/src/lib/labelColors.ts ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MISSING_LABEL_SENTINEL = "undefined";
2
+
3
+ export const MISSING_LABEL_COLOR = "#39d3cc"; // matches --accent-cyan
4
+ export const FALLBACK_LABEL_COLOR = "#8b949e"; // matches --muted-foreground
5
+
6
+ // Rerun-style auto colors: distribute hues by golden ratio conjugate.
7
+ // See: context/repos/rerun/crates/viewer/re_viewer_context/src/utils/color.rs
8
+ const GOLDEN_RATIO_CONJUGATE = (Math.sqrt(5) - 1) / 2; // ~0.61803398875
9
+
10
+ function fnv1a32(input: string): number {
11
+ // 32-bit FNV-1a hash
12
+ let hash = 0x811c9dc5;
13
+ for (let i = 0; i < input.length; i++) {
14
+ hash ^= input.charCodeAt(i);
15
+ hash = Math.imul(hash, 0x01000193);
16
+ }
17
+ return hash >>> 0;
18
+ }
19
+
20
+ function clamp01(v: number): number {
21
+ if (v < 0) return 0;
22
+ if (v > 1) return 1;
23
+ return v;
24
+ }
25
+
26
+ function hsvToHex(h: number, s: number, v: number): string {
27
+ // h/s/v in [0, 1]
28
+ const hh = ((h % 1) + 1) % 1;
29
+ const ss = clamp01(s);
30
+ const vv = clamp01(v);
31
+
32
+ const x = hh * 6;
33
+ const i = Math.floor(x);
34
+ const f = x - i;
35
+
36
+ const p = vv * (1 - ss);
37
+ const q = vv * (1 - ss * f);
38
+ const t = vv * (1 - ss * (1 - f));
39
+
40
+ let r = 0;
41
+ let g = 0;
42
+ let b = 0;
43
+
44
+ switch (i % 6) {
45
+ case 0:
46
+ r = vv;
47
+ g = t;
48
+ b = p;
49
+ break;
50
+ case 1:
51
+ r = q;
52
+ g = vv;
53
+ b = p;
54
+ break;
55
+ case 2:
56
+ r = p;
57
+ g = vv;
58
+ b = t;
59
+ break;
60
+ case 3:
61
+ r = p;
62
+ g = q;
63
+ b = vv;
64
+ break;
65
+ case 4:
66
+ r = t;
67
+ g = p;
68
+ b = vv;
69
+ break;
70
+ case 5:
71
+ r = vv;
72
+ g = p;
73
+ b = q;
74
+ break;
75
+ }
76
+
77
+ const toHex = (x: number) => {
78
+ const n = Math.round(clamp01(x) * 255);
79
+ return n.toString(16).padStart(2, "0");
80
+ };
81
+
82
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
83
+ }
84
+
85
+ export function labelToColor(label: string): string {
86
+ if (!label) return FALLBACK_LABEL_COLOR;
87
+ if (label === MISSING_LABEL_SENTINEL) return MISSING_LABEL_COLOR;
88
+
89
+ return colorForKey(label);
90
+ }
91
+
92
+ function colorForKey(key: string): string {
93
+ const seed = fnv1a32(key);
94
+ const val = seed & 0xffff; // map to u16
95
+ const h = (val * GOLDEN_RATIO_CONJUGATE) % 1;
96
+
97
+ // Match Rerun's defaults: saturation 0.85, value 0.5
98
+ return hsvToHex(h, 0.85, 0.5);
99
+ }
100
+
101
+ function stableLabelSort(a: string, b: string): number {
102
+ if (a === MISSING_LABEL_SENTINEL && b !== MISSING_LABEL_SENTINEL) return 1;
103
+ if (b === MISSING_LABEL_SENTINEL && a !== MISSING_LABEL_SENTINEL) return -1;
104
+ return a.localeCompare(b);
105
+ }
106
+
107
+ /**
108
+ * Builds a deterministic, collision-free label → color mapping for the given
109
+ * label universe.
110
+ *
111
+ * Notes:
112
+ * - No modulo/cycling: if there are N labels, we produce N colors.
113
+ * - Deterministic: same input set yields same mapping.
114
+ * - Collision-free within the provided set via deterministic rehashing.
115
+ */
116
+ export function createLabelColorMap(labels: string[]): Record<string, string> {
117
+ const unique = Array.from(new Set(labels.map((l) => normalizeLabel(l)))).sort(stableLabelSort);
118
+
119
+ const colors: Record<string, string> = {};
120
+ const used = new Set<string>();
121
+
122
+ for (const label of unique) {
123
+ if (label === MISSING_LABEL_SENTINEL) {
124
+ colors[label] = MISSING_LABEL_COLOR;
125
+ used.add(MISSING_LABEL_COLOR.toLowerCase());
126
+ continue;
127
+ }
128
+
129
+ let attempt = 0;
130
+ // Deterministic collision resolution (should be extremely rare).
131
+ while (attempt < 32) {
132
+ const candidate = colorForKey(attempt === 0 ? label : `${label}#${attempt}`);
133
+ const normalized = candidate.toLowerCase();
134
+ if (!used.has(normalized)) {
135
+ colors[label] = candidate;
136
+ used.add(normalized);
137
+ break;
138
+ }
139
+ attempt++;
140
+ }
141
+
142
+ if (!colors[label]) {
143
+ // Should never happen, but keep UI resilient.
144
+ colors[label] = FALLBACK_LABEL_COLOR;
145
+ }
146
+ }
147
+
148
+ return colors;
149
+ }
150
+
151
+ export function normalizeLabel(label: string | null | undefined): string {
152
+ return label && label.length > 0 ? label : MISSING_LABEL_SENTINEL;
153
+ }
frontend/src/lib/labelLegend.ts ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { EmbeddingsData } from "@/types";
2
+ import {
3
+ FALLBACK_LABEL_COLOR,
4
+ MISSING_LABEL_COLOR,
5
+ createLabelColorMap,
6
+ normalizeLabel,
7
+ } from "@/lib/labelColors";
8
+
9
+ // Past ~20-30 categories, color-as-encoding becomes unreliable for most users.
10
+ // We choose a conservative upper bound and fall back to a single color.
11
+ export const MAX_DISTINCT_LABEL_COLORS = 20;
12
+ export const UNSELECTED_LABEL_ALPHA = 0.12;
13
+
14
+ export interface ScatterLabelsInfo {
15
+ uniqueLabels: string[];
16
+ categories: Uint16Array;
17
+ palette: string[];
18
+ }
19
+
20
+ function clamp01(v: number): number {
21
+ if (v < 0) return 0;
22
+ if (v > 1) return 1;
23
+ return v;
24
+ }
25
+
26
+ function applyAlphaToHex(color: string, alpha: number): string {
27
+ if (!color.startsWith("#")) return color;
28
+ const hex = Math.round(clamp01(alpha) * 255)
29
+ .toString(16)
30
+ .padStart(2, "0");
31
+
32
+ if (color.length === 7) {
33
+ return `${color}${hex}`;
34
+ }
35
+
36
+ if (color.length === 9) {
37
+ return `${color.slice(0, 7)}${hex}`;
38
+ }
39
+
40
+ return color;
41
+ }
42
+
43
+ function applyLabelFilterToPalette(params: {
44
+ palette: string[];
45
+ labels: string[];
46
+ labelFilter: string | null;
47
+ unselectedAlpha: number;
48
+ }): string[] {
49
+ const { palette, labels, labelFilter, unselectedAlpha } = params;
50
+ if (!labelFilter) return palette;
51
+ if (!labels.includes(labelFilter)) return palette;
52
+
53
+ return palette.map((color, idx) =>
54
+ labels[idx] === labelFilter ? color : applyAlphaToHex(color, unselectedAlpha)
55
+ );
56
+ }
57
+
58
+ export function buildLabelCounts(embeddings: EmbeddingsData | null): Map<string, number> {
59
+ const counts = new Map<string, number>();
60
+ if (!embeddings) return counts;
61
+ for (const raw of embeddings.labels) {
62
+ const l = normalizeLabel(raw);
63
+ counts.set(l, (counts.get(l) ?? 0) + 1);
64
+ }
65
+ return counts;
66
+ }
67
+
68
+ export function getDistinctLabelCount(labelCounts: Map<string, number>): number {
69
+ let n = labelCounts.size;
70
+ if (labelCounts.has("undefined")) n -= 1;
71
+ return n;
72
+ }
73
+
74
+ export function buildLabelUniverse(
75
+ datasetLabels: string[],
76
+ embeddingsLabels: (string | null)[] | null
77
+ ): string[] {
78
+ const universe: string[] = [];
79
+ const seen = new Set<string>();
80
+ let hasMissing = false;
81
+
82
+ const baseLabels = datasetLabels.map((l) => normalizeLabel(l));
83
+ for (const l of baseLabels) {
84
+ if (l === "undefined") {
85
+ hasMissing = true;
86
+ continue;
87
+ }
88
+ if (seen.has(l)) continue;
89
+ seen.add(l);
90
+ universe.push(l);
91
+ }
92
+
93
+ if (embeddingsLabels) {
94
+ const extras = new Set<string>();
95
+ for (const raw of embeddingsLabels) {
96
+ const l = normalizeLabel(raw);
97
+ if (l === "undefined") {
98
+ hasMissing = true;
99
+ continue;
100
+ }
101
+ if (!seen.has(l)) extras.add(l);
102
+ }
103
+
104
+ if (extras.size > 0) {
105
+ const extraSorted = Array.from(extras).sort((a, b) => a.localeCompare(b));
106
+ for (const l of extraSorted) {
107
+ seen.add(l);
108
+ universe.push(l);
109
+ }
110
+ }
111
+ }
112
+
113
+ if (hasMissing) universe.push("undefined");
114
+ return universe;
115
+ }
116
+
117
+ export function buildLabelsInfo(params: {
118
+ datasetLabels: string[];
119
+ embeddings: EmbeddingsData | null;
120
+ distinctColoringDisabled: boolean;
121
+ labelFilter?: string | null;
122
+ unselectedAlpha?: number;
123
+ }): ScatterLabelsInfo | null {
124
+ const {
125
+ datasetLabels,
126
+ embeddings,
127
+ distinctColoringDisabled,
128
+ labelFilter = null,
129
+ unselectedAlpha = UNSELECTED_LABEL_ALPHA,
130
+ } = params;
131
+ if (!embeddings) return null;
132
+
133
+ const universe = buildLabelUniverse(datasetLabels, embeddings.labels);
134
+
135
+ // Guard: hyper-scatter categories are Uint16.
136
+ if (universe.length > 65535) {
137
+ console.warn(
138
+ `Too many labels (${universe.length}) for uint16 categories; collapsing to a single color.`
139
+ );
140
+ return {
141
+ uniqueLabels: ["undefined"],
142
+ categories: new Uint16Array(embeddings.labels.length),
143
+ palette: [FALLBACK_LABEL_COLOR],
144
+ };
145
+ }
146
+
147
+ const labelToCategory: Record<string, number> = {};
148
+ for (let i = 0; i < universe.length; i++) {
149
+ labelToCategory[universe[i]] = i;
150
+ }
151
+
152
+ const undefinedIndex = labelToCategory["undefined"] ?? 0;
153
+ const categories = new Uint16Array(embeddings.labels.length);
154
+ for (let i = 0; i < embeddings.labels.length; i++) {
155
+ const key = normalizeLabel(embeddings.labels[i]);
156
+ categories[i] = labelToCategory[key] ?? undefinedIndex;
157
+ }
158
+
159
+ let palette: string[];
160
+ if (distinctColoringDisabled) {
161
+ palette = universe.map((l) => (l === "undefined" ? MISSING_LABEL_COLOR : FALLBACK_LABEL_COLOR));
162
+ } else {
163
+ const colors = createLabelColorMap(universe);
164
+ palette = universe.map((l) => colors[l] ?? FALLBACK_LABEL_COLOR);
165
+ }
166
+
167
+ const filteredPalette = applyLabelFilterToPalette({
168
+ palette,
169
+ labels: universe,
170
+ labelFilter,
171
+ unselectedAlpha,
172
+ });
173
+
174
+ return { uniqueLabels: universe, categories, palette: filteredPalette };
175
+ }
176
+
177
+ export function buildLabelColorMap(params: {
178
+ labelsInfo: ScatterLabelsInfo | null;
179
+ labelUniverse: string[];
180
+ distinctColoringDisabled: boolean;
181
+ labelFilter?: string | null;
182
+ unselectedAlpha?: number;
183
+ }): Record<string, string> {
184
+ const {
185
+ labelsInfo,
186
+ labelUniverse,
187
+ distinctColoringDisabled,
188
+ labelFilter = null,
189
+ unselectedAlpha = UNSELECTED_LABEL_ALPHA,
190
+ } = params;
191
+ const map: Record<string, string> = {};
192
+
193
+ if (labelsInfo) {
194
+ for (let i = 0; i < labelsInfo.uniqueLabels.length; i++) {
195
+ map[labelsInfo.uniqueLabels[i]] = labelsInfo.palette[i] ?? FALLBACK_LABEL_COLOR;
196
+ }
197
+ return map;
198
+ }
199
+
200
+ if (labelUniverse.length === 0) return map;
201
+
202
+ if (distinctColoringDisabled) {
203
+ for (const label of labelUniverse) {
204
+ map[label] = label === "undefined" ? MISSING_LABEL_COLOR : FALLBACK_LABEL_COLOR;
205
+ }
206
+ return map;
207
+ }
208
+
209
+ const colors = createLabelColorMap(labelUniverse);
210
+ for (const label of labelUniverse) {
211
+ map[label] = colors[label] ?? FALLBACK_LABEL_COLOR;
212
+ }
213
+
214
+ if (!labelFilter || !labelUniverse.includes(labelFilter)) return map;
215
+
216
+ for (const label of labelUniverse) {
217
+ if (label !== labelFilter) {
218
+ map[label] = applyAlphaToHex(map[label], unselectedAlpha);
219
+ }
220
+ }
221
+
222
+ return map;
223
+ }
224
+
225
+ export function buildLegendLabels(params: {
226
+ labelUniverse: string[];
227
+ labelCounts: Map<string, number>;
228
+ query: string;
229
+ }): string[] {
230
+ const { labelUniverse, labelCounts, query } = params;
231
+ const all = labelUniverse.length > 0 ? [...labelUniverse] : Array.from(labelCounts.keys());
232
+ const q = query.trim().toLowerCase();
233
+ const filtered = q ? all.filter((l) => l.toLowerCase().includes(q)) : all;
234
+ const hasCounts = labelCounts.size > 0;
235
+
236
+ return filtered.sort((a, b) => {
237
+ if (a === "undefined" && b !== "undefined") return 1;
238
+ if (b === "undefined" && a !== "undefined") return -1;
239
+
240
+ if (hasCounts) {
241
+ const ca = labelCounts.get(a) ?? 0;
242
+ const cb = labelCounts.get(b) ?? 0;
243
+ if (cb !== ca) return cb - ca;
244
+ }
245
+
246
+ return a.localeCompare(b);
247
+ });
248
+ }
frontend/src/lib/layouts.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Geometry, LayoutInfo } from "@/types";
2
+
3
+ export function listAvailableGeometries(layouts: LayoutInfo[]): Geometry[] {
4
+ const geometries = new Set<Geometry>();
5
+ for (const layout of layouts) {
6
+ geometries.add(layout.geometry);
7
+ }
8
+ return Array.from(geometries);
9
+ }
10
+
11
+ export function findLayoutByGeometry(layouts: LayoutInfo[], geometry: Geometry): LayoutInfo | undefined {
12
+ return layouts.find((l) => l.geometry === geometry);
13
+ }
14
+
15
+ export function findLayoutByKey(layouts: LayoutInfo[], layoutKey: string): LayoutInfo | undefined {
16
+ return layouts.find((l) => l.layout_key === layoutKey);
17
+ }