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