Spaces:
Running
Running
github-actions[bot]
commited on
Commit
·
f45a0cf
1
Parent(s):
23680f2
Deploy hyper3labs/HyperView from Hyper3Labs/hyperview-spaces@f1c0a76
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +3 -53
- .gitattributes +0 -35
- .github/workflows/devin-review.yml +0 -23
- .github/workflows/require_frontend_export.yml +0 -53
- .gitignore +0 -74
- Dockerfile +11 -87
- LICENSE +0 -21
- README.md +18 -137
- app_hf.py +0 -49
- demo.py +140 -0
- docs/architecture.md +0 -54
- docs/colab.md +0 -37
- docs/datasets.md +0 -96
- docs/index.html +0 -465
- frontend/components.json +0 -22
- frontend/eslint.config.mjs +0 -22
- frontend/next-env.d.ts +0 -6
- frontend/next.config.ts +0 -24
- frontend/package-lock.json +0 -0
- frontend/package.json +0 -51
- frontend/postcss.config.mjs +0 -9
- frontend/src/app/globals.css +0 -224
- frontend/src/app/layout.tsx +0 -26
- frontend/src/app/page.tsx +0 -277
- frontend/src/components/DockviewWorkspace.tsx +0 -765
- frontend/src/components/ExplorerPanel.tsx +0 -181
- frontend/src/components/Header.tsx +0 -244
- frontend/src/components/ImageGrid.tsx +0 -338
- frontend/src/components/Panel.tsx +0 -43
- frontend/src/components/PanelHeader.tsx +0 -47
- frontend/src/components/PlaceholderPanel.tsx +0 -39
- frontend/src/components/ScatterPanel.tsx +0 -174
- frontend/src/components/icons.tsx +0 -73
- frontend/src/components/index.ts +0 -9
- frontend/src/components/ui/button.tsx +0 -57
- frontend/src/components/ui/collapsible.tsx +0 -11
- frontend/src/components/ui/command.tsx +0 -153
- frontend/src/components/ui/dialog.tsx +0 -122
- frontend/src/components/ui/dropdown-menu.tsx +0 -201
- frontend/src/components/ui/popover.tsx +0 -33
- frontend/src/components/ui/radio-group.tsx +0 -44
- frontend/src/components/ui/scroll-area.tsx +0 -49
- frontend/src/components/ui/separator.tsx +0 -31
- frontend/src/components/ui/toggle-group.tsx +0 -61
- frontend/src/components/ui/toggle.tsx +0 -45
- frontend/src/components/ui/tooltip.tsx +0 -32
- frontend/src/components/useHyperScatter.ts +0 -614
- frontend/src/components/useLabelLegend.ts +0 -85
- frontend/src/lib/api.ts +0 -103
- frontend/src/lib/labelColors.ts +0 -153
.dockerignore
CHANGED
|
@@ -1,65 +1,15 @@
|
|
| 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 |
-
#
|
| 21 |
-
|
| 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 |
-
#
|
| 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
|
|
|
|
| 1 |
# Git
|
| 2 |
.git
|
|
|
|
| 3 |
|
| 4 |
# Python
|
| 5 |
__pycache__
|
| 6 |
*.py[cod]
|
|
|
|
|
|
|
|
|
|
| 7 |
.venv
|
| 8 |
venv
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
# Caches
|
| 11 |
+
.mypy_cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
.pytest_cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
# Misc
|
|
|
|
| 15 |
.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.github/workflows/devin-review.yml
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,53 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,74 +0,0 @@
|
|
| 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
CHANGED
|
@@ -1,46 +1,5 @@
|
|
| 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 \
|
|
@@ -49,13 +8,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 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 \
|
|
@@ -64,60 +19,29 @@ ENV HOME=/home/user \
|
|
| 64 |
|
| 65 |
WORKDIR $HOME/app
|
| 66 |
|
| 67 |
-
# Upgrade pip
|
| 68 |
RUN pip install --upgrade pip
|
| 69 |
|
| 70 |
-
#
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# Install Python package (without ML extras - we use ONNX)
|
| 76 |
-
RUN pip install -e .
|
| 77 |
|
| 78 |
-
|
| 79 |
-
COPY --from=frontend-builder --chown=user /app/frontend/out ./src/hyperview/server/static/
|
| 80 |
|
| 81 |
-
|
| 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
|
| 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 |
-
|
| 123 |
-
CMD ["python", "app_hf.py"]
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
build-essential \
|
| 5 |
curl \
|
|
|
|
| 8 |
pkg-config \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
|
|
|
| 11 |
RUN useradd -m -u 1000 user
|
|
|
|
|
|
|
| 12 |
USER user
|
| 13 |
|
|
|
|
| 14 |
ENV HOME=/home/user \
|
| 15 |
PATH=/home/user/.local/bin:$PATH \
|
| 16 |
HF_HOME=/home/user/.cache/huggingface \
|
|
|
|
| 19 |
|
| 20 |
WORKDIR $HOME/app
|
| 21 |
|
|
|
|
| 22 |
RUN pip install --upgrade pip
|
| 23 |
|
| 24 |
+
# Install latest releases from PyPI.
|
| 25 |
+
# For reproducible builds, pin versions here:
|
| 26 |
+
# RUN pip install --upgrade hyperview==0.1.1 hyper-models==0.1.0
|
| 27 |
+
RUN pip install --upgrade hyperview hyper-models
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
COPY --chown=user demo.py ./demo.py
|
|
|
|
| 30 |
|
| 31 |
+
ARG DEMO_SAMPLES=300
|
|
|
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
ENV HYPERVIEW_DATASETS_DIR=/home/user/app/demo_data/datasets \
|
| 34 |
HYPERVIEW_MEDIA_DIR=/home/user/app/demo_data/media \
|
| 35 |
+
DEMO_SAMPLES=${DEMO_SAMPLES}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
RUN python demo.py --precompute
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
|
|
|
| 39 |
ENV HOST=0.0.0.0 \
|
| 40 |
+
PORT=7860
|
|
|
|
|
|
|
|
|
|
| 41 |
|
|
|
|
| 42 |
EXPOSE 7860
|
| 43 |
|
|
|
|
| 44 |
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 45 |
CMD curl -f http://localhost:7860/__hyperview__/health || exit 1
|
| 46 |
|
| 47 |
+
CMD ["python", "demo.py"]
|
|
|
LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
| 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
|
@@ -6,150 +6,31 @@ 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 |
-
|
| 29 |
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 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 |
-
|
| 149 |
|
| 150 |
-
-
|
| 151 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
##
|
| 154 |
|
| 155 |
-
|
|
|
|
|
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# HyperView — Imagenette (CLIP + HyCoCLIP)
|
| 12 |
|
| 13 |
+
This Hugging Face Space runs HyperView with:
|
| 14 |
|
| 15 |
+
- CLIP embeddings (`openai/clip-vit-base-patch32`) for Euclidean layout
|
| 16 |
+
- HyCoCLIP embeddings (`hycoclip-vit-s`) for Poincaré layout
|
| 17 |
|
| 18 |
+
The Docker image installs the **latest HyperView from PyPI** and precomputes
|
| 19 |
+
embeddings/layouts during build for fast runtime startup.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
## Configuration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
Environment variables:
|
| 24 |
|
| 25 |
+
- `DEMO_HF_DATASET` (default: `Multimodal-Fatima/Imagenette_validation`)
|
| 26 |
+
- `DEMO_HF_SPLIT` (default: `validation`)
|
| 27 |
+
- `DEMO_HF_IMAGE_KEY` (default: `image`)
|
| 28 |
+
- `DEMO_HF_LABEL_KEY` (default: `label`)
|
| 29 |
+
- `DEMO_SAMPLES` (default: `300`)
|
| 30 |
+
- `DEMO_CLIP_MODEL` (default: `openai/clip-vit-base-patch32`)
|
| 31 |
+
- `DEMO_HYPER_MODEL` (default: `hycoclip-vit-s`)
|
| 32 |
|
| 33 |
+
## Deploy source
|
| 34 |
|
| 35 |
+
This folder is synchronized to Hugging Face Spaces by GitHub Actions from the
|
| 36 |
+
`hyperview-spaces` deployment repository.
|
app_hf.py
DELETED
|
@@ -1,49 +0,0 @@
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
demo.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""HyperView Hugging Face Space demo: CLIP + HyCoCLIP on Imagenette.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python demo.py --precompute # run during Docker build
|
| 6 |
+
python demo.py # run as app entrypoint
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
|
| 14 |
+
import hyperview as hv
|
| 15 |
+
|
| 16 |
+
HOST = os.environ.get("HOST", "0.0.0.0")
|
| 17 |
+
PORT = int(os.environ.get("PORT", "7860"))
|
| 18 |
+
|
| 19 |
+
DATASET_NAME = os.environ.get("DEMO_DATASET", "imagenette_clip_hycoclip")
|
| 20 |
+
HF_DATASET = os.environ.get("DEMO_HF_DATASET", "Multimodal-Fatima/Imagenette_validation")
|
| 21 |
+
HF_SPLIT = os.environ.get("DEMO_HF_SPLIT", "validation")
|
| 22 |
+
HF_IMAGE_KEY = os.environ.get("DEMO_HF_IMAGE_KEY", "image")
|
| 23 |
+
HF_LABEL_KEY = os.environ.get("DEMO_HF_LABEL_KEY", "label")
|
| 24 |
+
NUM_SAMPLES = int(os.environ.get("DEMO_SAMPLES", "300"))
|
| 25 |
+
|
| 26 |
+
CLIP_MODEL_ID = os.environ.get("DEMO_CLIP_MODEL", "openai/clip-vit-base-patch32")
|
| 27 |
+
HYPER_MODEL_ID = os.environ.get("DEMO_HYPER_MODEL", "hycoclip-vit-s")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _truthy_env(name: str, default: bool = True) -> bool:
|
| 31 |
+
value = os.environ.get(name)
|
| 32 |
+
if value is None:
|
| 33 |
+
return default
|
| 34 |
+
return value.strip().lower() not in {"0", "false", "no", "off", ""}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _ensure_demo_ready(dataset: hv.Dataset) -> None:
|
| 38 |
+
if len(dataset) == 0:
|
| 39 |
+
print(f"Loading samples from {HF_DATASET} ({HF_SPLIT})...")
|
| 40 |
+
dataset.add_from_huggingface(
|
| 41 |
+
HF_DATASET,
|
| 42 |
+
split=HF_SPLIT,
|
| 43 |
+
image_key=HF_IMAGE_KEY,
|
| 44 |
+
label_key=HF_LABEL_KEY,
|
| 45 |
+
max_samples=NUM_SAMPLES,
|
| 46 |
+
shuffle=True,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
spaces = dataset.list_spaces()
|
| 50 |
+
|
| 51 |
+
clip_space = next(
|
| 52 |
+
(
|
| 53 |
+
space
|
| 54 |
+
for space in spaces
|
| 55 |
+
if getattr(space, "provider", None) == "embed-anything"
|
| 56 |
+
and getattr(space, "model_id", None) == CLIP_MODEL_ID
|
| 57 |
+
),
|
| 58 |
+
None,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
if clip_space is None:
|
| 62 |
+
print(f"Computing CLIP embeddings ({CLIP_MODEL_ID})...")
|
| 63 |
+
dataset.compute_embeddings(model=CLIP_MODEL_ID, provider="embed-anything", show_progress=True)
|
| 64 |
+
spaces = dataset.list_spaces()
|
| 65 |
+
clip_space = next(
|
| 66 |
+
(
|
| 67 |
+
space
|
| 68 |
+
for space in spaces
|
| 69 |
+
if getattr(space, "provider", None) == "embed-anything"
|
| 70 |
+
and getattr(space, "model_id", None) == CLIP_MODEL_ID
|
| 71 |
+
),
|
| 72 |
+
None,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
if clip_space is None:
|
| 76 |
+
raise RuntimeError("Failed to create CLIP embedding space")
|
| 77 |
+
|
| 78 |
+
compute_hyperbolic = _truthy_env("DEMO_COMPUTE_HYPERBOLIC", default=True)
|
| 79 |
+
hyper_space = next(
|
| 80 |
+
(
|
| 81 |
+
space
|
| 82 |
+
for space in spaces
|
| 83 |
+
if getattr(space, "provider", None) == "hyper-models"
|
| 84 |
+
and getattr(space, "model_id", None) == HYPER_MODEL_ID
|
| 85 |
+
),
|
| 86 |
+
None,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if compute_hyperbolic and hyper_space is None:
|
| 90 |
+
try:
|
| 91 |
+
print(f"Computing hyperbolic embeddings ({HYPER_MODEL_ID})...")
|
| 92 |
+
dataset.compute_embeddings(model=HYPER_MODEL_ID, provider="hyper-models", show_progress=True)
|
| 93 |
+
spaces = dataset.list_spaces()
|
| 94 |
+
hyper_space = next(
|
| 95 |
+
(
|
| 96 |
+
space
|
| 97 |
+
for space in spaces
|
| 98 |
+
if getattr(space, "provider", None) == "hyper-models"
|
| 99 |
+
and getattr(space, "model_id", None) == HYPER_MODEL_ID
|
| 100 |
+
),
|
| 101 |
+
None,
|
| 102 |
+
)
|
| 103 |
+
except Exception as exc:
|
| 104 |
+
print(f"WARNING: hyperbolic embeddings failed ({type(exc).__name__}: {exc})")
|
| 105 |
+
|
| 106 |
+
layouts = dataset.list_layouts()
|
| 107 |
+
geometries = {getattr(layout, "geometry", None) for layout in layouts}
|
| 108 |
+
|
| 109 |
+
if "euclidean" not in geometries:
|
| 110 |
+
print("Computing euclidean layout...")
|
| 111 |
+
dataset.compute_visualization(space_key=clip_space.space_key, geometry="euclidean")
|
| 112 |
+
|
| 113 |
+
if "poincare" not in geometries:
|
| 114 |
+
print("Computing poincaré layout...")
|
| 115 |
+
poincare_space_key = hyper_space.space_key if hyper_space is not None else clip_space.space_key
|
| 116 |
+
dataset.compute_visualization(space_key=poincare_space_key, geometry="poincare")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def main() -> None:
|
| 120 |
+
dataset = hv.Dataset(DATASET_NAME)
|
| 121 |
+
|
| 122 |
+
if len(dataset) == 0 or not dataset.list_layouts():
|
| 123 |
+
print("Preparing demo dataset...")
|
| 124 |
+
_ensure_demo_ready(dataset)
|
| 125 |
+
else:
|
| 126 |
+
print(
|
| 127 |
+
f"Loaded cached dataset '{DATASET_NAME}' with "
|
| 128 |
+
f"{len(dataset.list_spaces())} spaces and {len(dataset.list_layouts())} layouts"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
if "--precompute" in sys.argv:
|
| 132 |
+
print("Precompute complete")
|
| 133 |
+
return
|
| 134 |
+
|
| 135 |
+
print(f"Starting HyperView on {HOST}:{PORT}")
|
| 136 |
+
hv.launch(dataset, host=HOST, port=PORT, open_browser=False)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
main()
|
docs/architecture.md
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,465 +0,0 @@
|
|
| 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">×</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
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 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
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
DELETED
|
@@ -1,51 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,224 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,26 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,277 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,765 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,181 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,244 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,338 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,47 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,73 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,153 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,122 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,201 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,44 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,49 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,31 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,61 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,45 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,614 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,85 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,103 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,153 +0,0 @@
|
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|