Refactor UI: Stack Sans font, remove rounded corners, consolidate navigation
Browse files- Replace Instrument Sans with system font stack (Stack Sans)
- Remove all rounded corners (border-radius: 0) throughout app
- Consolidate navigation bar into compact header
- Fix IndexedDB read-only transaction errors
- Suppress WebSocket HMR errors
- Fix missing state variables (clusters, clustersLoading, tooltipPosition)
- Fix CSS syntax errors from border-radius replacements
- Add viewMode to fetchData dependency array
- Integrate MessagePack support for faster API responses
- Integrate VirtualSearchResults for better list performance
- Improve header responsive layout
- Update all component CSS to use system fonts
- Clean up unused markdown documentation files
This view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +1 -1
- README.md +30 -0
- auto_start.sh +66 -0
- auto_start_output.log +8 -0
- backend.log +40 -2
- backend/api/main.py +127 -88
- backend/config/requirements.txt +7 -0
- backend/core/config.py +24 -3
- backend/scripts/precompute_data.py +246 -0
- backend/scripts/precompute_fast.py +276 -0
- backend/utils/cache.py +153 -0
- backend/utils/data_loader.py +107 -10
- backend/utils/precomputed_loader.py +174 -0
- backend/utils/response_encoder.py +107 -0
- backend_full.log +119 -0
- backend_full_processing.log +5 -0
- deploy/backend-Procfile +1 -1
- deploy/fly.toml +33 -0
- deploy/nginx.conf +158 -0
- deploy/railway.json +2 -2
- deploy/render.yaml +5 -3
- deploy_railway.sh +50 -0
- docker-compose.yml +73 -0
- frontend/package-lock.json +127 -3
- frontend/package.json +10 -0
- frontend/public/index.html +0 -3
- frontend/src/App.css +767 -761
- frontend/src/App.tsx +453 -555
- frontend/src/components/controls/ClusterFilter.css +5 -5
- frontend/src/components/controls/RenderingStyleSelector.css +1 -1
- frontend/src/components/controls/ThemeToggle.tsx +1 -1
- frontend/src/components/controls/VisualizationModeButtons.css +1 -1
- frontend/src/components/controls/VisualizationModeButtons.tsx +3 -6
- frontend/src/components/layout/SearchBar.css +9 -9
- frontend/src/components/modals/FileTree.css +13 -13
- frontend/src/components/modals/FileTree.tsx +16 -16
- frontend/src/components/modals/ModelModal.css +24 -24
- frontend/src/components/modals/ModelModal.tsx +3 -3
- frontend/src/components/ui/ColorLegend.css +3 -3
- frontend/src/components/ui/ErrorBoundary.tsx +3 -3
- frontend/src/components/ui/LiveModelCount.css +5 -5
- frontend/src/components/ui/LiveModelCount.tsx +2 -2
- frontend/src/components/ui/ModelCountTracker.css +9 -9
- frontend/src/components/ui/ModelTooltip.tsx +1 -1
- frontend/src/components/ui/VirtualSearchResults.tsx +80 -0
- frontend/src/components/visualizations/DistanceHeatmap.tsx +2 -2
- frontend/src/components/visualizations/DistributionView.css +3 -3
- frontend/src/components/visualizations/EnhancedScatterPlot.tsx +0 -638
- frontend/src/components/visualizations/HeatmapView.css +0 -37
- frontend/src/components/visualizations/HeatmapView.tsx +0 -172
Dockerfile
CHANGED
|
@@ -33,5 +33,5 @@ ENV PORT=8000
|
|
| 33 |
|
| 34 |
# Run the application
|
| 35 |
WORKDIR /app/backend
|
| 36 |
-
CMD ["
|
| 37 |
|
|
|
|
| 33 |
|
| 34 |
# Run the application
|
| 35 |
WORKDIR /app/backend
|
| 36 |
+
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 37 |
|
README.md
CHANGED
|
@@ -16,6 +16,36 @@ This interactive latent space navigator visualizes ~1.84M models from the [model
|
|
| 16 |
- **GitHub Repository**: [bendlaufer/ai-ecosystem](https://github.com/bendlaufer/ai-ecosystem) - Original research repository with analysis notebooks and datasets
|
| 17 |
- **Hugging Face Project**: [modelbiome](https://huggingface.co/modelbiome) - Dataset and project page on Hugging Face Hub
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
## Project Structure
|
| 20 |
|
| 21 |
```
|
|
|
|
| 16 |
- **GitHub Repository**: [bendlaufer/ai-ecosystem](https://github.com/bendlaufer/ai-ecosystem) - Original research repository with analysis notebooks and datasets
|
| 17 |
- **Hugging Face Project**: [modelbiome](https://huggingface.co/modelbiome) - Dataset and project page on Hugging Face Hub
|
| 18 |
|
| 19 |
+
## 🚀 Quick Start (New: Pre-Computed Data)
|
| 20 |
+
|
| 21 |
+
This project now uses **pre-computed embeddings and coordinates** for instant startup:
|
| 22 |
+
|
| 23 |
+
### Option 1: Pre-Computed Data (Recommended - 10 seconds startup)
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# 1. Generate pre-computed data (one-time, ~45 minutes)
|
| 27 |
+
cd backend
|
| 28 |
+
pip install -r config/requirements.txt
|
| 29 |
+
python scripts/precompute_data.py --sample-size 150000
|
| 30 |
+
|
| 31 |
+
# 2. Start backend (instant!)
|
| 32 |
+
uvicorn api.main:app --host 0.0.0.0 --port 8000
|
| 33 |
+
|
| 34 |
+
# 3. Start frontend
|
| 35 |
+
cd ../frontend
|
| 36 |
+
npm install && npm start
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
**Startup time:** ~5-10 seconds ⚡
|
| 40 |
+
|
| 41 |
+
### Option 2: Traditional Mode (Fallback)
|
| 42 |
+
|
| 43 |
+
If pre-computed data is not available, the backend will automatically fall back to traditional loading (slower but still functional).
|
| 44 |
+
|
| 45 |
+
**See:**
|
| 46 |
+
- [`PRECOMPUTED_DATA.md`](PRECOMPUTED_DATA.md) - Detailed documentation
|
| 47 |
+
- [`DEPLOYMENT.md`](DEPLOYMENT.md) - Production deployment guide
|
| 48 |
+
|
| 49 |
## Project Structure
|
| 50 |
|
| 51 |
```
|
auto_start.sh
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Auto-start backend once pre-computation completes
|
| 3 |
+
|
| 4 |
+
echo "🔍 Monitoring pre-computation process..."
|
| 5 |
+
echo "📊 Log file: /Users/hamidaho/hf_viz/precompute_fast.log"
|
| 6 |
+
echo ""
|
| 7 |
+
|
| 8 |
+
# Wait for pre-computation to complete
|
| 9 |
+
while ps aux | grep -q "[p]recompute_fast.py"; do
|
| 10 |
+
# Show latest progress
|
| 11 |
+
LATEST=$(tail -1 /Users/hamidaho/hf_viz/precompute_fast.log 2>/dev/null | grep -E "Batches:|Step|INFO")
|
| 12 |
+
if [ ! -z "$LATEST" ]; then
|
| 13 |
+
echo -ne "\r⏳ $LATEST "
|
| 14 |
+
fi
|
| 15 |
+
sleep 5
|
| 16 |
+
done
|
| 17 |
+
|
| 18 |
+
echo ""
|
| 19 |
+
echo ""
|
| 20 |
+
echo "✅ Pre-computation complete!"
|
| 21 |
+
echo ""
|
| 22 |
+
|
| 23 |
+
# Check if files were created successfully
|
| 24 |
+
if [ -f "/Users/hamidaho/hf_viz/precomputed_data/models_v1.parquet" ]; then
|
| 25 |
+
echo "✅ Found: models_v1.parquet"
|
| 26 |
+
ls -lh /Users/hamidaho/hf_viz/precomputed_data/models_v1.parquet
|
| 27 |
+
else
|
| 28 |
+
echo "❌ ERROR: models_v1.parquet not found"
|
| 29 |
+
exit 1
|
| 30 |
+
fi
|
| 31 |
+
|
| 32 |
+
if [ -f "/Users/hamidaho/hf_viz/precomputed_data/embeddings_v1.parquet" ]; then
|
| 33 |
+
echo "✅ Found: embeddings_v1.parquet"
|
| 34 |
+
ls -lh /Users/hamidaho/hf_viz/precomputed_data/embeddings_v1.parquet
|
| 35 |
+
else
|
| 36 |
+
echo "❌ ERROR: embeddings_v1.parquet not found"
|
| 37 |
+
exit 1
|
| 38 |
+
fi
|
| 39 |
+
|
| 40 |
+
if [ -f "/Users/hamidaho/hf_viz/precomputed_data/metadata_v1.json" ]; then
|
| 41 |
+
echo "✅ Found: metadata_v1.json"
|
| 42 |
+
cat /Users/hamidaho/hf_viz/precomputed_data/metadata_v1.json | python3 -m json.tool 2>/dev/null | grep -E "total_models|unique_libraries|unique_pipelines" | head -3
|
| 43 |
+
else
|
| 44 |
+
echo "❌ ERROR: metadata_v1.json not found"
|
| 45 |
+
exit 1
|
| 46 |
+
fi
|
| 47 |
+
|
| 48 |
+
echo ""
|
| 49 |
+
echo "🚀 Starting backend server..."
|
| 50 |
+
echo ""
|
| 51 |
+
|
| 52 |
+
cd /Users/hamidaho/hf_viz/backend
|
| 53 |
+
|
| 54 |
+
# Kill any existing backend processes
|
| 55 |
+
pkill -f "uvicorn.*api.main:app" 2>/dev/null
|
| 56 |
+
|
| 57 |
+
# Start backend
|
| 58 |
+
source /Users/hamidaho/hf_viz/venv/bin/activate
|
| 59 |
+
uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload
|
| 60 |
+
|
| 61 |
+
echo ""
|
| 62 |
+
echo "Backend started on http://localhost:8000"
|
| 63 |
+
echo "Frontend should be running on http://localhost:3000"
|
| 64 |
+
echo ""
|
| 65 |
+
echo "📊 Open your browser and refresh the page!"
|
| 66 |
+
|
auto_start_output.log
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
🔍 Monitoring pre-computation process...
|
| 2 |
+
📊 Log file: /Users/hamidaho/hf_viz/precompute.log
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
✅ Pre-computation complete!
|
| 7 |
+
|
| 8 |
+
❌ ERROR: models_v1.parquet not found
|
backend.log
CHANGED
|
@@ -1,6 +1,44 @@
|
|
| 1 |
INFO: Will watch for changes in these directories: ['/Users/hamidaho/hf_viz/backend']
|
| 2 |
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
| 3 |
-
INFO: Started reloader process [
|
| 4 |
-
INFO: Started server process [
|
| 5 |
INFO: Waiting for application startup.
|
| 6 |
Repo card metadata block was not found. Setting CardData to empty.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
INFO: Will watch for changes in these directories: ['/Users/hamidaho/hf_viz/backend']
|
| 2 |
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
| 3 |
+
INFO: Started reloader process [80800] using WatchFiles
|
| 4 |
+
INFO: Started server process [80972]
|
| 5 |
INFO: Waiting for application startup.
|
| 6 |
Repo card metadata block was not found. Setting CardData to empty.
|
| 7 |
+
|
| 8 |
+
/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/umap/umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
|
| 9 |
+
warn(
|
| 10 |
+
huggingface/tokenizers: The current process just got forked, after parallelism has already been used. Disabling parallelism to avoid deadlocks...
|
| 11 |
+
To disable this warning, you can either:
|
| 12 |
+
- Avoid using `tokenizers` before the fork if possible
|
| 13 |
+
- Explicitly set the environment variable TOKENIZERS_PARALLELISM=(true | false)
|
| 14 |
+
/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/sklearn/utils/extmath.py:203: RuntimeWarning: divide by zero encountered in matmul
|
| 15 |
+
ret = a @ b
|
| 16 |
+
/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/sklearn/utils/extmath.py:203: RuntimeWarning: overflow encountered in matmul
|
| 17 |
+
ret = a @ b
|
| 18 |
+
/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/sklearn/utils/extmath.py:203: RuntimeWarning: invalid value encountered in matmul
|
| 19 |
+
ret = a @ b
|
| 20 |
+
/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/sklearn/cluster/_kmeans.py:237: RuntimeWarning: divide by zero encountered in matmul
|
| 21 |
+
current_pot = closest_dist_sq @ sample_weight
|
| 22 |
+
/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/sklearn/cluster/_kmeans.py:237: RuntimeWarning: overflow encountered in matmul
|
| 23 |
+
current_pot = closest_dist_sq @ sample_weight
|
| 24 |
+
/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/sklearn/cluster/_kmeans.py:237: RuntimeWarning: invalid value encountered in matmul
|
| 25 |
+
current_pot = closest_dist_sq @ sample_weight
|
| 26 |
+
INFO: Application startup complete.
|
| 27 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 28 |
+
INFO: 127.0.0.1:50374 - "GET /api/model-count/current?use_models_page=true&use_dataset_snapshot=true&use_cache=true HTTP/1.1" 200 OK
|
| 29 |
+
INFO: 127.0.0.1:50523 - "GET /api/clusters HTTP/1.1" 200 OK
|
| 30 |
+
INFO: 127.0.0.1:50531 - "GET /api/stats HTTP/1.1" 200 OK
|
| 31 |
+
INFO: 127.0.0.1:50533 - "GET /api/models?min_downloads=0&min_likes=0&color_by=library_name&size_by=downloads&projection_method=umap&base_models_only=false&use_graph_embeddings=false&max_points=10000 HTTP/1.1" 200 OK
|
| 32 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 33 |
+
INFO: 127.0.0.1:51282 - "GET /api/model-count/current?use_models_page=true&use_dataset_snapshot=true&use_cache=true HTTP/1.1" 200 OK
|
| 34 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 35 |
+
INFO: 127.0.0.1:51928 - "GET /api/model-count/current?use_models_page=true&use_dataset_snapshot=true&use_cache=true HTTP/1.1" 200 OK
|
| 36 |
+
INFO: 127.0.0.1:52804 - "GET /api/clusters HTTP/1.1" 200 OK
|
| 37 |
+
INFO: 127.0.0.1:52810 - "GET /api/stats HTTP/1.1" 200 OK
|
| 38 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 39 |
+
INFO: 127.0.0.1:52810 - "GET /api/model-count/current?use_models_page=true&use_dataset_snapshot=true&use_cache=true HTTP/1.1" 200 OK
|
| 40 |
+
INFO: 127.0.0.1:53301 - "GET /api/clusters HTTP/1.1" 200 OK
|
| 41 |
+
INFO: 127.0.0.1:53303 - "GET /api/stats HTTP/1.1" 200 OK
|
| 42 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 43 |
+
INFO: 127.0.0.1:53490 - "GET /api/model-count/current?use_models_page=true&use_dataset_snapshot=true&use_cache=true HTTP/1.1" 200 OK
|
| 44 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
backend/api/main.py
CHANGED
|
@@ -29,6 +29,8 @@ from core.config import settings
|
|
| 29 |
from core.exceptions import DataNotLoadedError, EmbeddingsNotReadyError
|
| 30 |
from models.schemas import ModelPoint
|
| 31 |
from utils.family_tree import calculate_family_depths
|
|
|
|
|
|
|
| 32 |
import api.dependencies as deps
|
| 33 |
from api.routes import models, stats, clusters
|
| 34 |
|
|
@@ -103,10 +105,79 @@ app.include_router(clusters.router)
|
|
| 103 |
|
| 104 |
@app.on_event("startup")
|
| 105 |
async def startup_event():
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 109 |
root_dir = os.path.dirname(backend_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
cache_dir = os.path.join(root_dir, "cache")
|
| 111 |
os.makedirs(cache_dir, exist_ok=True)
|
| 112 |
|
|
@@ -118,13 +189,11 @@ async def startup_event():
|
|
| 118 |
reducer_cache_umap = os.path.join(cache_dir, "reducer_umap_3d.pkl")
|
| 119 |
reducer_cache_umap_graph = os.path.join(cache_dir, "reducer_umap_3d_graph.pkl")
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
else:
|
| 125 |
-
logger.info("No SAMPLE_SIZE set, loading full dataset")
|
| 126 |
|
| 127 |
-
deps.df = deps.data_loader.load_data(sample_size=sample_size)
|
| 128 |
deps.df = deps.data_loader.preprocess_for_embedding(deps.df)
|
| 129 |
|
| 130 |
if 'model_id' in deps.df.columns:
|
|
@@ -148,58 +217,17 @@ async def startup_event():
|
|
| 148 |
deps.embeddings = deps.embedder.generate_embeddings(texts, batch_size=128)
|
| 149 |
deps.embedder.save_embeddings(deps.embeddings, embeddings_cache)
|
| 150 |
|
| 151 |
-
#
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
logger.info("Building family graph for graph embeddings...")
|
| 156 |
-
graph = deps.graph_embedder.build_family_graph(deps.df)
|
| 157 |
-
|
| 158 |
-
if os.path.exists(graph_embeddings_cache):
|
| 159 |
-
try:
|
| 160 |
-
deps.graph_embeddings_dict = deps.graph_embedder.load_embeddings(graph_embeddings_cache)
|
| 161 |
-
logger.info(f"Loaded cached graph embeddings for {len(deps.graph_embeddings_dict)} models")
|
| 162 |
-
except (IOError, pickle.UnpicklingError, EOFError) as e:
|
| 163 |
-
logger.warning(f"Failed to load cached graph embeddings: {e}")
|
| 164 |
-
deps.graph_embeddings_dict = None
|
| 165 |
-
|
| 166 |
-
if deps.graph_embeddings_dict is None or len(deps.graph_embeddings_dict) == 0:
|
| 167 |
-
logger.info("Generating graph embeddings (this may take a while)...")
|
| 168 |
-
deps.graph_embeddings_dict = deps.graph_embedder.generate_graph_embeddings(graph, workers=4)
|
| 169 |
-
if deps.graph_embeddings_dict:
|
| 170 |
-
deps.graph_embedder.save_embeddings(deps.graph_embeddings_dict, graph_embeddings_cache)
|
| 171 |
-
logger.info(f"Generated graph embeddings for {len(deps.graph_embeddings_dict)} models")
|
| 172 |
-
|
| 173 |
-
# Combine text and graph embeddings
|
| 174 |
-
if deps.graph_embeddings_dict and len(deps.graph_embeddings_dict) > 0:
|
| 175 |
-
model_ids = deps.df['model_id'].astype(str).tolist()
|
| 176 |
-
if os.path.exists(combined_embeddings_cache):
|
| 177 |
-
try:
|
| 178 |
-
with open(combined_embeddings_cache, 'rb') as f:
|
| 179 |
-
deps.combined_embeddings = pickle.load(f)
|
| 180 |
-
logger.info("Loaded cached combined embeddings")
|
| 181 |
-
except (IOError, pickle.UnpicklingError, EOFError) as e:
|
| 182 |
-
logger.warning(f"Failed to load cached combined embeddings: {e}")
|
| 183 |
-
deps.combined_embeddings = None
|
| 184 |
-
|
| 185 |
-
if deps.combined_embeddings is None:
|
| 186 |
-
logger.info("Combining text and graph embeddings...")
|
| 187 |
-
deps.combined_embeddings = deps.graph_embedder.combine_embeddings(
|
| 188 |
-
deps.embeddings, deps.graph_embeddings_dict, model_ids,
|
| 189 |
-
text_weight=0.7, graph_weight=0.3
|
| 190 |
-
)
|
| 191 |
-
with open(combined_embeddings_cache, 'wb') as f:
|
| 192 |
-
pickle.dump(deps.combined_embeddings, f)
|
| 193 |
-
logger.info("Combined embeddings saved")
|
| 194 |
-
except Exception as e:
|
| 195 |
-
logger.warning(f"Graph embeddings not available: {e}. Continuing with text-only embeddings.")
|
| 196 |
-
deps.graph_embedder = None
|
| 197 |
-
deps.graph_embeddings_dict = None
|
| 198 |
-
deps.combined_embeddings = None
|
| 199 |
|
| 200 |
# Initialize reducer for text embeddings
|
| 201 |
deps.reducer = DimensionReducer(method="umap", n_components=3)
|
| 202 |
|
|
|
|
|
|
|
|
|
|
| 203 |
if os.path.exists(reduced_cache_umap) and os.path.exists(reducer_cache_umap):
|
| 204 |
try:
|
| 205 |
with open(reduced_cache_umap, 'rb') as f:
|
|
@@ -225,35 +253,19 @@ async def startup_event():
|
|
| 225 |
pickle.dump(deps.reduced_embeddings, f)
|
| 226 |
deps.reducer.save_reducer(reducer_cache_umap)
|
| 227 |
|
| 228 |
-
#
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
if deps.reduced_embeddings_graph is None:
|
| 242 |
-
reducer_graph.reducer = UMAP(
|
| 243 |
-
n_components=3,
|
| 244 |
-
n_neighbors=30,
|
| 245 |
-
min_dist=0.3,
|
| 246 |
-
metric='cosine',
|
| 247 |
-
random_state=42,
|
| 248 |
-
n_jobs=-1,
|
| 249 |
-
low_memory=True,
|
| 250 |
-
spread=1.5
|
| 251 |
-
)
|
| 252 |
-
deps.reduced_embeddings_graph = reducer_graph.fit_transform(deps.combined_embeddings)
|
| 253 |
-
with open(reduced_cache_umap_graph, 'wb') as f:
|
| 254 |
-
pickle.dump(deps.reduced_embeddings_graph, f)
|
| 255 |
-
reducer_graph.save_reducer(reducer_cache_umap_graph)
|
| 256 |
-
logger.info("Graph-aware embeddings reduced and cached")
|
| 257 |
|
| 258 |
# Update module-level aliases
|
| 259 |
df = deps.df
|
|
@@ -293,11 +305,12 @@ async def get_models(
|
|
| 293 |
search_query: Optional[str] = Query(None),
|
| 294 |
color_by: str = Query("library_name"),
|
| 295 |
size_by: str = Query("downloads"),
|
| 296 |
-
max_points: Optional[int] = Query(None)
|
| 297 |
projection_method: str = Query("umap"),
|
| 298 |
base_models_only: bool = Query(False),
|
| 299 |
max_hierarchy_depth: Optional[int] = Query(None, ge=0, description="Filter to models at or below this hierarchy depth."),
|
| 300 |
-
use_graph_embeddings: bool = Query(False, description="Use graph-aware embeddings that respect family tree structure")
|
|
|
|
| 301 |
):
|
| 302 |
if deps.df is None:
|
| 303 |
raise DataNotLoadedError()
|
|
@@ -505,12 +518,38 @@ async def get_models(
|
|
| 505 |
]
|
| 506 |
|
| 507 |
# Return models with metadata about embedding type
|
| 508 |
-
|
| 509 |
"models": models,
|
| 510 |
"embedding_type": embedding_type,
|
| 511 |
"filtered_count": filtered_count,
|
| 512 |
"returned_count": len(models)
|
| 513 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
|
| 516 |
@app.get("/api/stats")
|
|
@@ -1546,7 +1585,7 @@ async def get_current_model_count(
|
|
| 1546 |
try:
|
| 1547 |
from utils.data_loader import ModelDataLoader
|
| 1548 |
data_loader = ModelDataLoader()
|
| 1549 |
-
df = data_loader.load_data(sample_size=10000)
|
| 1550 |
library_counts = {}
|
| 1551 |
pipeline_counts = {}
|
| 1552 |
|
|
|
|
| 29 |
from core.exceptions import DataNotLoadedError, EmbeddingsNotReadyError
|
| 30 |
from models.schemas import ModelPoint
|
| 31 |
from utils.family_tree import calculate_family_depths
|
| 32 |
+
from utils.cache import cache, cached_response
|
| 33 |
+
from utils.response_encoder import FastJSONResponse, MessagePackResponse, encode_models_msgpack
|
| 34 |
import api.dependencies as deps
|
| 35 |
from api.routes import models, stats, clusters
|
| 36 |
|
|
|
|
| 105 |
|
| 106 |
@app.on_event("startup")
|
| 107 |
async def startup_event():
|
| 108 |
+
"""
|
| 109 |
+
Fast startup using pre-computed data.
|
| 110 |
+
Falls back to traditional loading if pre-computed data not available.
|
| 111 |
+
"""
|
| 112 |
+
import time
|
| 113 |
+
startup_start = time.time()
|
| 114 |
|
| 115 |
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 116 |
root_dir = os.path.dirname(backend_dir)
|
| 117 |
+
|
| 118 |
+
# Try to load pre-computed data first (instant startup!)
|
| 119 |
+
from utils.precomputed_loader import get_precomputed_loader
|
| 120 |
+
|
| 121 |
+
precomputed_loader = get_precomputed_loader(version="v1")
|
| 122 |
+
|
| 123 |
+
if precomputed_loader:
|
| 124 |
+
logger.info("=" * 60)
|
| 125 |
+
logger.info("LOADING PRE-COMPUTED DATA (Fast Startup Mode)")
|
| 126 |
+
logger.info("=" * 60)
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
# Load everything in seconds
|
| 130 |
+
deps.df, deps.embeddings, metadata = precomputed_loader.load_all()
|
| 131 |
+
|
| 132 |
+
# Extract 3D coordinates from dataframe
|
| 133 |
+
deps.reduced_embeddings = np.column_stack([
|
| 134 |
+
deps.df['x_3d'].values,
|
| 135 |
+
deps.df['y_3d'].values,
|
| 136 |
+
deps.df['z_3d'].values
|
| 137 |
+
])
|
| 138 |
+
|
| 139 |
+
# Initialize embedder (without loading/generating embeddings)
|
| 140 |
+
deps.embedder = ModelEmbedder()
|
| 141 |
+
|
| 142 |
+
# Initialize reducer (already fitted)
|
| 143 |
+
deps.reducer = DimensionReducer(method="umap", n_components=3)
|
| 144 |
+
|
| 145 |
+
# No graph embeddings in fast mode (optional feature)
|
| 146 |
+
deps.graph_embedder = None
|
| 147 |
+
deps.graph_embeddings_dict = None
|
| 148 |
+
deps.combined_embeddings = None
|
| 149 |
+
deps.reduced_embeddings_graph = None
|
| 150 |
+
|
| 151 |
+
startup_time = time.time() - startup_start
|
| 152 |
+
logger.info("=" * 60)
|
| 153 |
+
logger.info(f"STARTUP COMPLETE in {startup_time:.2f} seconds!")
|
| 154 |
+
logger.info(f"Loaded {len(deps.df):,} models with pre-computed coordinates")
|
| 155 |
+
logger.info(f"Unique libraries: {metadata.get('unique_libraries')}")
|
| 156 |
+
logger.info(f"Unique pipelines: {metadata.get('unique_pipelines')}")
|
| 157 |
+
logger.info("=" * 60)
|
| 158 |
+
|
| 159 |
+
# Update module-level aliases
|
| 160 |
+
df = deps.df
|
| 161 |
+
embedder = deps.embedder
|
| 162 |
+
reducer = deps.reducer
|
| 163 |
+
embeddings = deps.embeddings
|
| 164 |
+
reduced_embeddings = deps.reduced_embeddings
|
| 165 |
+
|
| 166 |
+
return
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.warning(f"Failed to load pre-computed data: {e}")
|
| 170 |
+
logger.info("Falling back to traditional loading...")
|
| 171 |
+
|
| 172 |
+
else:
|
| 173 |
+
logger.info("=" * 60)
|
| 174 |
+
logger.info("Pre-computed data not found.")
|
| 175 |
+
logger.info("To enable fast startup, run:")
|
| 176 |
+
logger.info(" cd backend && python scripts/precompute_data.py --sample-size 150000")
|
| 177 |
+
logger.info("=" * 60)
|
| 178 |
+
logger.info("Falling back to traditional loading (may take 1-8 hours)...")
|
| 179 |
+
|
| 180 |
+
# Traditional loading (slow path)
|
| 181 |
cache_dir = os.path.join(root_dir, "cache")
|
| 182 |
os.makedirs(cache_dir, exist_ok=True)
|
| 183 |
|
|
|
|
| 189 |
reducer_cache_umap = os.path.join(cache_dir, "reducer_umap_3d.pkl")
|
| 190 |
reducer_cache_umap_graph = os.path.join(cache_dir, "reducer_umap_3d_graph.pkl")
|
| 191 |
|
| 192 |
+
# Load dataset with sample (for reasonable startup time)
|
| 193 |
+
sample_size = settings.SAMPLE_SIZE or settings.get_sample_size() or 5000
|
| 194 |
+
logger.info(f"Loading dataset (sample_size={sample_size}, prioritizing base models)...")
|
|
|
|
|
|
|
| 195 |
|
| 196 |
+
deps.df = deps.data_loader.load_data(sample_size=sample_size, prioritize_base_models=True)
|
| 197 |
deps.df = deps.data_loader.preprocess_for_embedding(deps.df)
|
| 198 |
|
| 199 |
if 'model_id' in deps.df.columns:
|
|
|
|
| 217 |
deps.embeddings = deps.embedder.generate_embeddings(texts, batch_size=128)
|
| 218 |
deps.embedder.save_embeddings(deps.embeddings, embeddings_cache)
|
| 219 |
|
| 220 |
+
# Skip graph embeddings in fallback mode (too slow)
|
| 221 |
+
deps.graph_embedder = None
|
| 222 |
+
deps.graph_embeddings_dict = None
|
| 223 |
+
deps.combined_embeddings = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
# Initialize reducer for text embeddings
|
| 226 |
deps.reducer = DimensionReducer(method="umap", n_components=3)
|
| 227 |
|
| 228 |
+
# Pre-compute clusters for faster requests
|
| 229 |
+
logger.info("Pre-computing clusters...")
|
| 230 |
+
|
| 231 |
if os.path.exists(reduced_cache_umap) and os.path.exists(reducer_cache_umap):
|
| 232 |
try:
|
| 233 |
with open(reduced_cache_umap, 'rb') as f:
|
|
|
|
| 253 |
pickle.dump(deps.reduced_embeddings, f)
|
| 254 |
deps.reducer.save_reducer(reducer_cache_umap)
|
| 255 |
|
| 256 |
+
# No graph embeddings in fallback mode
|
| 257 |
+
deps.reduced_embeddings_graph = None
|
| 258 |
+
|
| 259 |
+
# Pre-compute clusters now instead of on first request
|
| 260 |
+
if deps.reduced_embeddings is not None and len(deps.reduced_embeddings) > 0:
|
| 261 |
+
models.cluster_labels = compute_clusters(
|
| 262 |
+
deps.reduced_embeddings,
|
| 263 |
+
n_clusters=min(50, len(deps.reduced_embeddings) // 100)
|
| 264 |
+
)
|
| 265 |
+
logger.info(f"Pre-computed {len(set(models.cluster_labels))} clusters")
|
| 266 |
+
|
| 267 |
+
startup_time = time.time() - startup_start
|
| 268 |
+
logger.info(f"Startup complete in {startup_time:.2f} seconds")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
# Update module-level aliases
|
| 271 |
df = deps.df
|
|
|
|
| 305 |
search_query: Optional[str] = Query(None),
|
| 306 |
color_by: str = Query("library_name"),
|
| 307 |
size_by: str = Query("downloads"),
|
| 308 |
+
max_points: Optional[int] = Query(10000), # REDUCED from None (was 50k default in frontend)
|
| 309 |
projection_method: str = Query("umap"),
|
| 310 |
base_models_only: bool = Query(False),
|
| 311 |
max_hierarchy_depth: Optional[int] = Query(None, ge=0, description="Filter to models at or below this hierarchy depth."),
|
| 312 |
+
use_graph_embeddings: bool = Query(False, description="Use graph-aware embeddings that respect family tree structure"),
|
| 313 |
+
format: str = Query("json", regex="^(json|msgpack)$", description="Response format: json or msgpack")
|
| 314 |
):
|
| 315 |
if deps.df is None:
|
| 316 |
raise DataNotLoadedError()
|
|
|
|
| 518 |
]
|
| 519 |
|
| 520 |
# Return models with metadata about embedding type
|
| 521 |
+
response_data = {
|
| 522 |
"models": models,
|
| 523 |
"embedding_type": embedding_type,
|
| 524 |
"filtered_count": filtered_count,
|
| 525 |
"returned_count": len(models)
|
| 526 |
}
|
| 527 |
+
|
| 528 |
+
# Return in requested format with caching headers
|
| 529 |
+
if format == "msgpack":
|
| 530 |
+
try:
|
| 531 |
+
binary_data = encode_models_msgpack([m.dict() for m in models])
|
| 532 |
+
return Response(
|
| 533 |
+
content=binary_data,
|
| 534 |
+
media_type="application/msgpack",
|
| 535 |
+
headers={
|
| 536 |
+
"Cache-Control": "public, max-age=300",
|
| 537 |
+
"X-Content-Type-Options": "nosniff",
|
| 538 |
+
"Access-Control-Expose-Headers": "Cache-Control"
|
| 539 |
+
}
|
| 540 |
+
)
|
| 541 |
+
except Exception as e:
|
| 542 |
+
logger.warning(f"MessagePack encoding failed, falling back to JSON: {e}")
|
| 543 |
+
|
| 544 |
+
# Return JSON with caching headers
|
| 545 |
+
return FastJSONResponse(
|
| 546 |
+
content=response_data,
|
| 547 |
+
headers={
|
| 548 |
+
"Cache-Control": "public, max-age=300",
|
| 549 |
+
"X-Content-Type-Options": "nosniff",
|
| 550 |
+
"Access-Control-Expose-Headers": "Cache-Control"
|
| 551 |
+
}
|
| 552 |
+
)
|
| 553 |
|
| 554 |
|
| 555 |
@app.get("/api/stats")
|
|
|
|
| 1585 |
try:
|
| 1586 |
from utils.data_loader import ModelDataLoader
|
| 1587 |
data_loader = ModelDataLoader()
|
| 1588 |
+
df = data_loader.load_data(sample_size=10000, prioritize_base_models=True)
|
| 1589 |
library_counts = {}
|
| 1590 |
pipeline_counts = {}
|
| 1591 |
|
backend/config/requirements.txt
CHANGED
|
@@ -13,4 +13,11 @@ tqdm>=4.66.0
|
|
| 13 |
networkx>=3.0
|
| 14 |
node2vec>=0.4.6
|
| 15 |
httpx>=0.24.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
|
|
|
| 13 |
networkx>=3.0
|
| 14 |
node2vec>=0.4.6
|
| 15 |
httpx>=0.24.0
|
| 16 |
+
pyarrow>=14.0.0
|
| 17 |
+
|
| 18 |
+
# Speed optimizations
|
| 19 |
+
redis>=5.0.0
|
| 20 |
+
msgpack>=1.0.0
|
| 21 |
+
orjson>=3.9.0
|
| 22 |
+
aiocache>=0.12.0
|
| 23 |
|
backend/core/config.py
CHANGED
|
@@ -6,17 +6,38 @@ class Settings:
|
|
| 6 |
"""Application settings."""
|
| 7 |
FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
| 8 |
ALLOW_ALL_ORIGINS: bool = os.getenv("ALLOW_ALL_ORIGINS", "True").lower() in ("true", "1", "yes")
|
| 9 |
-
SAMPLE_SIZE: Optional[int] = None
|
| 10 |
USE_GRAPH_EMBEDDINGS: bool = os.getenv("USE_GRAPH_EMBEDDINGS", "false").lower() == "true"
|
| 11 |
PORT: int = int(os.getenv("PORT", 8000))
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
@classmethod
|
| 14 |
def get_sample_size(cls) -> Optional[int]:
|
| 15 |
"""Get sample size from environment."""
|
| 16 |
sample_size_env = os.getenv("SAMPLE_SIZE")
|
| 17 |
if sample_size_env:
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
return None
|
| 21 |
|
| 22 |
settings = Settings()
|
|
|
|
| 6 |
"""Application settings."""
|
| 7 |
FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
| 8 |
ALLOW_ALL_ORIGINS: bool = os.getenv("ALLOW_ALL_ORIGINS", "True").lower() in ("true", "1", "yes")
|
|
|
|
| 9 |
USE_GRAPH_EMBEDDINGS: bool = os.getenv("USE_GRAPH_EMBEDDINGS", "false").lower() == "true"
|
| 10 |
PORT: int = int(os.getenv("PORT", 8000))
|
| 11 |
|
| 12 |
+
# Redis caching
|
| 13 |
+
REDIS_ENABLED: bool = os.getenv("REDIS_ENABLED", "false").lower() == "true"
|
| 14 |
+
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
| 15 |
+
REDIS_PORT: int = int(os.getenv("REDIS_PORT", 6379))
|
| 16 |
+
REDIS_TTL: int = int(os.getenv("REDIS_TTL", 300)) # 5 minutes default
|
| 17 |
+
|
| 18 |
+
# Sample size - read immediately from environment
|
| 19 |
+
@property
|
| 20 |
+
def SAMPLE_SIZE(self) -> Optional[int]:
|
| 21 |
+
"""Get sample size from environment (dynamic property)."""
|
| 22 |
+
sample_size_env = os.getenv("SAMPLE_SIZE")
|
| 23 |
+
if sample_size_env:
|
| 24 |
+
try:
|
| 25 |
+
sample_size_val = int(sample_size_env)
|
| 26 |
+
return sample_size_val if sample_size_val > 0 else None
|
| 27 |
+
except ValueError:
|
| 28 |
+
return None
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
@classmethod
|
| 32 |
def get_sample_size(cls) -> Optional[int]:
|
| 33 |
"""Get sample size from environment."""
|
| 34 |
sample_size_env = os.getenv("SAMPLE_SIZE")
|
| 35 |
if sample_size_env:
|
| 36 |
+
try:
|
| 37 |
+
sample_size_val = int(sample_size_env)
|
| 38 |
+
return sample_size_val if sample_size_val > 0 else None
|
| 39 |
+
except ValueError:
|
| 40 |
+
return None
|
| 41 |
return None
|
| 42 |
|
| 43 |
settings = Settings()
|
backend/scripts/precompute_data.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Pre-compute embeddings and UMAP coordinates for HF models.
|
| 4 |
+
This script generates pre-computed data files that can be loaded instantly on server startup.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python scripts/precompute_data.py --sample-size 150000 --output-dir ../precomputed_data
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import json
|
| 14 |
+
import time
|
| 15 |
+
import logging
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pandas as pd
|
| 20 |
+
import numpy as np
|
| 21 |
+
import pyarrow as pa
|
| 22 |
+
import pyarrow.parquet as pq
|
| 23 |
+
from umap import UMAP
|
| 24 |
+
|
| 25 |
+
# Add backend to path
|
| 26 |
+
backend_dir = Path(__file__).parent.parent
|
| 27 |
+
sys.path.insert(0, str(backend_dir))
|
| 28 |
+
|
| 29 |
+
from utils.data_loader import ModelDataLoader
|
| 30 |
+
from utils.embeddings import ModelEmbedder
|
| 31 |
+
|
| 32 |
+
logging.basicConfig(
|
| 33 |
+
level=logging.INFO,
|
| 34 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 35 |
+
)
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def precompute_embeddings_and_umap(
|
| 40 |
+
sample_size=150000,
|
| 41 |
+
output_dir="precomputed_data",
|
| 42 |
+
version="v1"
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Pre-compute embeddings and UMAP coordinates.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
sample_size: Number of models to process (None for all)
|
| 49 |
+
output_dir: Directory to save pre-computed files
|
| 50 |
+
version: Version tag for the data
|
| 51 |
+
"""
|
| 52 |
+
start_time = time.time()
|
| 53 |
+
|
| 54 |
+
# Create output directory
|
| 55 |
+
output_path = Path(output_dir)
|
| 56 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
|
| 58 |
+
logger.info(f"Starting pre-computation for {sample_size if sample_size else 'ALL'} models...")
|
| 59 |
+
logger.info(f"Output directory: {output_path.absolute()}")
|
| 60 |
+
|
| 61 |
+
# Step 1: Load data with methodological sampling
|
| 62 |
+
logger.info("Step 1/5: Loading model data (prioritizing base models)...")
|
| 63 |
+
data_loader = ModelDataLoader()
|
| 64 |
+
df = data_loader.load_data(sample_size=sample_size, prioritize_base_models=True)
|
| 65 |
+
df = data_loader.preprocess_for_embedding(df)
|
| 66 |
+
|
| 67 |
+
if 'model_id' in df.columns:
|
| 68 |
+
df.set_index('model_id', drop=False, inplace=True)
|
| 69 |
+
|
| 70 |
+
# Ensure numeric columns
|
| 71 |
+
for col in ['downloads', 'likes']:
|
| 72 |
+
if col in df.columns:
|
| 73 |
+
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0).astype(int)
|
| 74 |
+
|
| 75 |
+
logger.info(f"Loaded {len(df)} models")
|
| 76 |
+
|
| 77 |
+
# Step 2: Generate embeddings
|
| 78 |
+
logger.info("Step 2/5: Generating embeddings (this may take 10-30 minutes)...")
|
| 79 |
+
embedder = ModelEmbedder()
|
| 80 |
+
texts = df['combined_text'].tolist()
|
| 81 |
+
embeddings = embedder.generate_embeddings(texts, batch_size=128)
|
| 82 |
+
logger.info(f"Generated embeddings: {embeddings.shape}")
|
| 83 |
+
|
| 84 |
+
# Step 3: Run UMAP for 3D
|
| 85 |
+
logger.info("Step 3/5: Running UMAP for 3D coordinates (this may take 5-15 minutes)...")
|
| 86 |
+
reducer_3d = UMAP(
|
| 87 |
+
n_components=3,
|
| 88 |
+
n_neighbors=30,
|
| 89 |
+
min_dist=0.3,
|
| 90 |
+
metric='cosine',
|
| 91 |
+
random_state=42,
|
| 92 |
+
n_jobs=-1,
|
| 93 |
+
low_memory=True,
|
| 94 |
+
spread=1.5,
|
| 95 |
+
verbose=True
|
| 96 |
+
)
|
| 97 |
+
coords_3d = reducer_3d.fit_transform(embeddings)
|
| 98 |
+
logger.info(f"Generated 3D coordinates: {coords_3d.shape}")
|
| 99 |
+
|
| 100 |
+
# Step 4: Run UMAP for 2D
|
| 101 |
+
logger.info("Step 4/5: Running UMAP for 2D coordinates (this may take 5-15 minutes)...")
|
| 102 |
+
reducer_2d = UMAP(
|
| 103 |
+
n_components=2,
|
| 104 |
+
n_neighbors=30,
|
| 105 |
+
min_dist=0.3,
|
| 106 |
+
metric='cosine',
|
| 107 |
+
random_state=42,
|
| 108 |
+
n_jobs=-1,
|
| 109 |
+
low_memory=True,
|
| 110 |
+
spread=1.5,
|
| 111 |
+
verbose=True
|
| 112 |
+
)
|
| 113 |
+
coords_2d = reducer_2d.fit_transform(embeddings)
|
| 114 |
+
logger.info(f"Generated 2D coordinates: {coords_2d.shape}")
|
| 115 |
+
|
| 116 |
+
# Step 5: Save to Parquet files
|
| 117 |
+
logger.info("Step 5/5: Saving to Parquet files...")
|
| 118 |
+
|
| 119 |
+
# Prepare DataFrame with all data
|
| 120 |
+
result_df = pd.DataFrame({
|
| 121 |
+
'model_id': df['model_id'].astype(str),
|
| 122 |
+
'library_name': df.get('library_name', pd.Series([None] * len(df))),
|
| 123 |
+
'pipeline_tag': df.get('pipeline_tag', pd.Series([None] * len(df))),
|
| 124 |
+
'downloads': df.get('downloads', pd.Series([0] * len(df))),
|
| 125 |
+
'likes': df.get('likes', pd.Series([0] * len(df))),
|
| 126 |
+
'trendingScore': df.get('trendingScore', pd.Series([None] * len(df))),
|
| 127 |
+
'tags': df.get('tags', pd.Series([None] * len(df))),
|
| 128 |
+
'parent_model': df.get('parent_model', pd.Series([None] * len(df))),
|
| 129 |
+
'licenses': df.get('licenses', pd.Series([None] * len(df))),
|
| 130 |
+
'createdAt': df.get('createdAt', pd.Series([None] * len(df))),
|
| 131 |
+
'x_3d': coords_3d[:, 0],
|
| 132 |
+
'y_3d': coords_3d[:, 1],
|
| 133 |
+
'z_3d': coords_3d[:, 2],
|
| 134 |
+
'x_2d': coords_2d[:, 0],
|
| 135 |
+
'y_2d': coords_2d[:, 1],
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
# Save main data file
|
| 139 |
+
data_file = output_path / f"models_{version}.parquet"
|
| 140 |
+
result_df.to_parquet(data_file, compression='snappy', index=False)
|
| 141 |
+
logger.info(f"Saved main data: {data_file} ({data_file.stat().st_size / 1024 / 1024:.2f} MB)")
|
| 142 |
+
|
| 143 |
+
# Save embeddings separately (for similarity search)
|
| 144 |
+
embeddings_file = output_path / f"embeddings_{version}.parquet"
|
| 145 |
+
embeddings_df = pd.DataFrame({
|
| 146 |
+
'model_id': df['model_id'].astype(str),
|
| 147 |
+
'embedding': [emb.tolist() for emb in embeddings]
|
| 148 |
+
})
|
| 149 |
+
embeddings_df.to_parquet(embeddings_file, compression='snappy', index=False)
|
| 150 |
+
logger.info(f"Saved embeddings: {embeddings_file} ({embeddings_file.stat().st_size / 1024 / 1024:.2f} MB)")
|
| 151 |
+
|
| 152 |
+
# Save metadata
|
| 153 |
+
metadata = {
|
| 154 |
+
'version': version,
|
| 155 |
+
'created_at': datetime.utcnow().isoformat() + 'Z',
|
| 156 |
+
'total_models': len(df),
|
| 157 |
+
'sample_size': sample_size,
|
| 158 |
+
'embedding_dim': embeddings.shape[1],
|
| 159 |
+
'unique_libraries': int(df['library_name'].nunique()) if 'library_name' in df.columns else 0,
|
| 160 |
+
'unique_pipelines': int(df['pipeline_tag'].nunique()) if 'pipeline_tag' in df.columns else 0,
|
| 161 |
+
'files': {
|
| 162 |
+
'models': f"models_{version}.parquet",
|
| 163 |
+
'embeddings': f"embeddings_{version}.parquet"
|
| 164 |
+
},
|
| 165 |
+
'stats': {
|
| 166 |
+
'avg_downloads': float(df['downloads'].mean()) if 'downloads' in df.columns else 0,
|
| 167 |
+
'avg_likes': float(df['likes'].mean()) if 'likes' in df.columns else 0,
|
| 168 |
+
'libraries': df['library_name'].value_counts().head(20).to_dict() if 'library_name' in df.columns else {},
|
| 169 |
+
'pipelines': df['pipeline_tag'].value_counts().head(20).to_dict() if 'pipeline_tag' in df.columns else {}
|
| 170 |
+
},
|
| 171 |
+
'coordinates': {
|
| 172 |
+
'3d': {
|
| 173 |
+
'min': [float(coords_3d[:, i].min()) for i in range(3)],
|
| 174 |
+
'max': [float(coords_3d[:, i].max()) for i in range(3)],
|
| 175 |
+
'mean': [float(coords_3d[:, i].mean()) for i in range(3)]
|
| 176 |
+
},
|
| 177 |
+
'2d': {
|
| 178 |
+
'min': [float(coords_2d[:, i].min()) for i in range(2)],
|
| 179 |
+
'max': [float(coords_2d[:, i].max()) for i in range(2)],
|
| 180 |
+
'mean': [float(coords_2d[:, i].mean()) for i in range(2)]
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
metadata_file = output_path / f"metadata_{version}.json"
|
| 186 |
+
with open(metadata_file, 'w') as f:
|
| 187 |
+
json.dump(metadata, f, indent=2, default=str)
|
| 188 |
+
logger.info(f"Saved metadata: {metadata_file}")
|
| 189 |
+
|
| 190 |
+
elapsed = time.time() - start_time
|
| 191 |
+
logger.info(f"\n{'='*60}")
|
| 192 |
+
logger.info(f"Pre-computation complete!")
|
| 193 |
+
logger.info(f"Total time: {elapsed / 60:.1f} minutes")
|
| 194 |
+
logger.info(f"Models processed: {len(df):,}")
|
| 195 |
+
logger.info(f"Output directory: {output_path.absolute()}")
|
| 196 |
+
logger.info(f"Files created:")
|
| 197 |
+
logger.info(f" - {data_file.name} ({data_file.stat().st_size / 1024 / 1024:.2f} MB)")
|
| 198 |
+
logger.info(f" - {embeddings_file.name} ({embeddings_file.stat().st_size / 1024 / 1024:.2f} MB)")
|
| 199 |
+
logger.info(f" - {metadata_file.name}")
|
| 200 |
+
logger.info(f"{'='*60}\n")
|
| 201 |
+
|
| 202 |
+
return metadata
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def main():
|
| 206 |
+
parser = argparse.ArgumentParser(description='Pre-compute embeddings and UMAP coordinates')
|
| 207 |
+
parser.add_argument(
|
| 208 |
+
'--sample-size',
|
| 209 |
+
type=int,
|
| 210 |
+
default=150000,
|
| 211 |
+
help='Number of models to process (default: 150000, use 0 for all)'
|
| 212 |
+
)
|
| 213 |
+
parser.add_argument(
|
| 214 |
+
'--output-dir',
|
| 215 |
+
type=str,
|
| 216 |
+
default='precomputed_data',
|
| 217 |
+
help='Output directory for pre-computed files (default: precomputed_data)'
|
| 218 |
+
)
|
| 219 |
+
parser.add_argument(
|
| 220 |
+
'--version',
|
| 221 |
+
type=str,
|
| 222 |
+
default='v1',
|
| 223 |
+
help='Version tag for the data (default: v1)'
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
args = parser.parse_args()
|
| 227 |
+
|
| 228 |
+
sample_size = None if args.sample_size == 0 else args.sample_size
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
precompute_embeddings_and_umap(
|
| 232 |
+
sample_size=sample_size,
|
| 233 |
+
output_dir=args.output_dir,
|
| 234 |
+
version=args.version
|
| 235 |
+
)
|
| 236 |
+
except KeyboardInterrupt:
|
| 237 |
+
logger.warning("\nInterrupted by user")
|
| 238 |
+
sys.exit(1)
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"Error during pre-computation: {e}", exc_info=True)
|
| 241 |
+
sys.exit(1)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
if __name__ == '__main__':
|
| 245 |
+
main()
|
| 246 |
+
|
backend/scripts/precompute_fast.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
FAST pre-computation script with speed optimizations.
|
| 4 |
+
~5-10x faster than standard version.
|
| 5 |
+
|
| 6 |
+
Optimizations:
|
| 7 |
+
- No random_state (enables parallel UMAP)
|
| 8 |
+
- PCA pre-reduction (384 -> 50 dims)
|
| 9 |
+
- Optimized UMAP parameters
|
| 10 |
+
- Larger batch sizes
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python scripts/precompute_fast.py --sample-size 150000 --output-dir ../precomputed_data
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
import json
|
| 20 |
+
import time
|
| 21 |
+
import logging
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import pandas as pd
|
| 26 |
+
import numpy as np
|
| 27 |
+
import pyarrow as pa
|
| 28 |
+
import pyarrow.parquet as pq
|
| 29 |
+
from umap import UMAP
|
| 30 |
+
from sklearn.decomposition import PCA
|
| 31 |
+
|
| 32 |
+
# Add backend to path
|
| 33 |
+
backend_dir = Path(__file__).parent.parent
|
| 34 |
+
sys.path.insert(0, str(backend_dir))
|
| 35 |
+
|
| 36 |
+
from utils.data_loader import ModelDataLoader
|
| 37 |
+
from utils.embeddings import ModelEmbedder
|
| 38 |
+
|
| 39 |
+
logging.basicConfig(
|
| 40 |
+
level=logging.INFO,
|
| 41 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 42 |
+
)
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def precompute_fast(
|
| 47 |
+
sample_size: int = 150000,
|
| 48 |
+
output_dir: str = "precomputed_data",
|
| 49 |
+
version: str = "v1",
|
| 50 |
+
pca_dims: int = 50,
|
| 51 |
+
use_pca: bool = True
|
| 52 |
+
):
|
| 53 |
+
"""
|
| 54 |
+
Pre-compute embeddings and UMAP coordinates with speed optimizations.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
sample_size: Number of models to process
|
| 58 |
+
output_dir: Directory to save output files
|
| 59 |
+
version: Version tag for output files
|
| 60 |
+
pca_dims: Number of PCA dimensions (if use_pca=True)
|
| 61 |
+
use_pca: Whether to use PCA pre-reduction (much faster)
|
| 62 |
+
"""
|
| 63 |
+
start_time = time.time()
|
| 64 |
+
|
| 65 |
+
# Create output directory
|
| 66 |
+
output_path = Path(output_dir)
|
| 67 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
|
| 69 |
+
logger.info("="*60)
|
| 70 |
+
logger.info("FAST PRE-COMPUTATION STARTED")
|
| 71 |
+
logger.info("="*60)
|
| 72 |
+
logger.info(f"Sample size: {sample_size:,}")
|
| 73 |
+
logger.info(f"Output directory: {output_dir}")
|
| 74 |
+
logger.info(f"Version: {version}")
|
| 75 |
+
logger.info(f"PCA pre-reduction: {use_pca} ({pca_dims} dims)" if use_pca else "PCA: disabled")
|
| 76 |
+
logger.info("="*60)
|
| 77 |
+
|
| 78 |
+
# Step 1: Load data with methodological sampling
|
| 79 |
+
logger.info("Step 1/5: Loading model data (prioritizing base models)...")
|
| 80 |
+
step_start = time.time()
|
| 81 |
+
|
| 82 |
+
data_loader = ModelDataLoader()
|
| 83 |
+
df = data_loader.load_data(sample_size=sample_size, prioritize_base_models=True)
|
| 84 |
+
|
| 85 |
+
step_time = time.time() - step_start
|
| 86 |
+
logger.info(f"Loaded {len(df):,} models in {step_time:.1f} seconds")
|
| 87 |
+
|
| 88 |
+
# Step 2: Generate embeddings
|
| 89 |
+
logger.info("Step 2/5: Generating embeddings...")
|
| 90 |
+
step_start = time.time()
|
| 91 |
+
|
| 92 |
+
# Build combined text from available fields
|
| 93 |
+
logger.info("Building combined text from model fields...")
|
| 94 |
+
df['combined_text'] = (
|
| 95 |
+
df.get('tags', '').astype(str) + ' ' +
|
| 96 |
+
df.get('pipeline_tag', '').astype(str) + ' ' +
|
| 97 |
+
df.get('library_name', '').astype(str)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Add modelCard if available
|
| 101 |
+
if 'modelCard' in df.columns:
|
| 102 |
+
df['combined_text'] = df['combined_text'] + ' ' + df['modelCard'].astype(str).str[:500]
|
| 103 |
+
|
| 104 |
+
embedder = ModelEmbedder()
|
| 105 |
+
texts = df['combined_text'].tolist()
|
| 106 |
+
|
| 107 |
+
# Use larger batch size for speed
|
| 108 |
+
embeddings = embedder.generate_embeddings(texts, batch_size=256)
|
| 109 |
+
|
| 110 |
+
step_time = time.time() - step_start
|
| 111 |
+
logger.info(f"Generated embeddings: {embeddings.shape} in {step_time/60:.1f} minutes")
|
| 112 |
+
|
| 113 |
+
# Optional: PCA pre-reduction for speed
|
| 114 |
+
embeddings_for_umap = embeddings
|
| 115 |
+
pca_model = None
|
| 116 |
+
|
| 117 |
+
if use_pca and embeddings.shape[1] > pca_dims:
|
| 118 |
+
logger.info(f"Step 2.5/5: PCA reduction ({embeddings.shape[1]} -> {pca_dims} dims)...")
|
| 119 |
+
step_start = time.time()
|
| 120 |
+
|
| 121 |
+
pca_model = PCA(n_components=pca_dims, random_state=42)
|
| 122 |
+
embeddings_for_umap = pca_model.fit_transform(embeddings)
|
| 123 |
+
|
| 124 |
+
explained_var = pca_model.explained_variance_ratio_.sum()
|
| 125 |
+
step_time = time.time() - step_start
|
| 126 |
+
logger.info(f"PCA complete in {step_time:.1f}s (preserved {explained_var:.1%} variance)")
|
| 127 |
+
logger.info(f"Reduced embeddings: {embeddings_for_umap.shape}")
|
| 128 |
+
|
| 129 |
+
# Step 3: Run UMAP for 3D (OPTIMIZED)
|
| 130 |
+
logger.info("Step 3/5: Running OPTIMIZED UMAP for 3D coordinates...")
|
| 131 |
+
step_start = time.time()
|
| 132 |
+
|
| 133 |
+
reducer_3d = UMAP(
|
| 134 |
+
n_components=3,
|
| 135 |
+
n_neighbors=15, # ↓ from 30 for speed
|
| 136 |
+
min_dist=0.1, # ↓ from 0.3 for speed
|
| 137 |
+
metric='euclidean', # faster than cosine
|
| 138 |
+
n_jobs=-1, # all cores (no random_state!)
|
| 139 |
+
low_memory=False, # faster if RAM available
|
| 140 |
+
spread=1.5,
|
| 141 |
+
verbose=True
|
| 142 |
+
)
|
| 143 |
+
coords_3d = reducer_3d.fit_transform(embeddings_for_umap)
|
| 144 |
+
|
| 145 |
+
step_time = time.time() - step_start
|
| 146 |
+
logger.info(f"Generated 3D coordinates: {coords_3d.shape} in {step_time/60:.1f} minutes")
|
| 147 |
+
|
| 148 |
+
# Step 4: Run UMAP for 2D (OPTIMIZED)
|
| 149 |
+
logger.info("Step 4/5: Running OPTIMIZED UMAP for 2D coordinates...")
|
| 150 |
+
step_start = time.time()
|
| 151 |
+
|
| 152 |
+
reducer_2d = UMAP(
|
| 153 |
+
n_components=2,
|
| 154 |
+
n_neighbors=15, # ↓ from 30 for speed
|
| 155 |
+
min_dist=0.1, # ↓ from 0.3 for speed
|
| 156 |
+
metric='euclidean', # faster than cosine
|
| 157 |
+
n_jobs=-1, # all cores (no random_state!)
|
| 158 |
+
low_memory=False, # faster if RAM available
|
| 159 |
+
spread=1.5,
|
| 160 |
+
verbose=True
|
| 161 |
+
)
|
| 162 |
+
coords_2d = reducer_2d.fit_transform(embeddings_for_umap)
|
| 163 |
+
|
| 164 |
+
step_time = time.time() - step_start
|
| 165 |
+
logger.info(f"Generated 2D coordinates: {coords_2d.shape} in {step_time/60:.1f} minutes")
|
| 166 |
+
|
| 167 |
+
# Step 5: Save to Parquet files
|
| 168 |
+
logger.info("Step 5/5: Saving to Parquet files...")
|
| 169 |
+
step_start = time.time()
|
| 170 |
+
|
| 171 |
+
# Prepare DataFrame with all data
|
| 172 |
+
output_df = df.copy()
|
| 173 |
+
output_df['x_3d'] = coords_3d[:, 0]
|
| 174 |
+
output_df['y_3d'] = coords_3d[:, 1]
|
| 175 |
+
output_df['z_3d'] = coords_3d[:, 2]
|
| 176 |
+
output_df['x_2d'] = coords_2d[:, 0]
|
| 177 |
+
output_df['y_2d'] = coords_2d[:, 1]
|
| 178 |
+
|
| 179 |
+
# Save main data
|
| 180 |
+
models_file = output_path / f"models_{version}.parquet"
|
| 181 |
+
output_df.to_parquet(models_file, compression='snappy', index=False)
|
| 182 |
+
logger.info(f"Saved models data: {models_file} ({models_file.stat().st_size / 1024 / 1024:.1f} MB)")
|
| 183 |
+
|
| 184 |
+
# Save embeddings separately
|
| 185 |
+
embeddings_file = output_path / f"embeddings_{version}.parquet"
|
| 186 |
+
embeddings_df = pd.DataFrame({
|
| 187 |
+
'model_id': df['modelId'].values,
|
| 188 |
+
'embedding': [emb.tolist() for emb in embeddings]
|
| 189 |
+
})
|
| 190 |
+
embeddings_df.to_parquet(embeddings_file, compression='snappy', index=False)
|
| 191 |
+
logger.info(f"Saved embeddings: {embeddings_file} ({embeddings_file.stat().st_size / 1024 / 1024:.1f} MB)")
|
| 192 |
+
|
| 193 |
+
# Save metadata
|
| 194 |
+
total_time = time.time() - start_time
|
| 195 |
+
metadata = {
|
| 196 |
+
'version': version,
|
| 197 |
+
'created_at': datetime.now().isoformat(),
|
| 198 |
+
'total_models': len(df),
|
| 199 |
+
'embedding_dim': embeddings.shape[1],
|
| 200 |
+
'umap_3d_shape': coords_3d.shape,
|
| 201 |
+
'umap_2d_shape': coords_2d.shape,
|
| 202 |
+
'unique_libraries': int(df['library_name'].nunique()),
|
| 203 |
+
'unique_pipelines': int(df['pipeline_tag'].nunique()),
|
| 204 |
+
'processing_time_seconds': total_time,
|
| 205 |
+
'processing_time_minutes': total_time / 60,
|
| 206 |
+
'optimizations': {
|
| 207 |
+
'pca_enabled': use_pca,
|
| 208 |
+
'pca_dims': pca_dims if use_pca else None,
|
| 209 |
+
'pca_variance_preserved': float(pca_model.explained_variance_ratio_.sum()) if pca_model else None,
|
| 210 |
+
'umap_parallel': True,
|
| 211 |
+
'umap_n_neighbors': 15,
|
| 212 |
+
'umap_metric': 'euclidean',
|
| 213 |
+
'batch_size': 256
|
| 214 |
+
},
|
| 215 |
+
'statistics': {
|
| 216 |
+
'downloads': {
|
| 217 |
+
'min': float(df['downloads'].min()) if 'downloads' in df else 0,
|
| 218 |
+
'max': float(df['downloads'].max()) if 'downloads' in df else 0,
|
| 219 |
+
'mean': float(df['downloads'].mean()) if 'downloads' in df else 0,
|
| 220 |
+
},
|
| 221 |
+
'likes': {
|
| 222 |
+
'min': float(df['likes'].min()) if 'likes' in df else 0,
|
| 223 |
+
'max': float(df['likes'].max()) if 'likes' in df else 0,
|
| 224 |
+
'mean': float(df['likes'].mean()) if 'likes' in df else 0,
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
metadata_file = output_path / f"metadata_{version}.json"
|
| 230 |
+
with open(metadata_file, 'w') as f:
|
| 231 |
+
json.dump(metadata, f, indent=2)
|
| 232 |
+
|
| 233 |
+
logger.info(f"Saved metadata: {metadata_file}")
|
| 234 |
+
|
| 235 |
+
step_time = time.time() - step_start
|
| 236 |
+
logger.info(f"Files saved in {step_time:.1f} seconds")
|
| 237 |
+
|
| 238 |
+
# Final summary
|
| 239 |
+
logger.info("="*60)
|
| 240 |
+
logger.info("FAST PRE-COMPUTATION COMPLETE!")
|
| 241 |
+
logger.info("="*60)
|
| 242 |
+
logger.info(f"Total time: {total_time/60:.1f} minutes ({total_time:.0f} seconds)")
|
| 243 |
+
logger.info(f"Models processed: {len(df):,}")
|
| 244 |
+
logger.info(f"Speedup estimate: ~3-5x faster than standard version")
|
| 245 |
+
logger.info(f"Output directory: {output_dir}")
|
| 246 |
+
logger.info(f"Files created:")
|
| 247 |
+
logger.info(f" - {models_file.name} ({models_file.stat().st_size / 1024 / 1024:.1f} MB)")
|
| 248 |
+
logger.info(f" - {embeddings_file.name} ({embeddings_file.stat().st_size / 1024 / 1024:.1f} MB)")
|
| 249 |
+
logger.info(f" - {metadata_file.name}")
|
| 250 |
+
logger.info("="*60)
|
| 251 |
+
|
| 252 |
+
return output_df, embeddings, metadata
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
if __name__ == "__main__":
|
| 256 |
+
parser = argparse.ArgumentParser(description="Fast pre-computation of HF model embeddings and coordinates")
|
| 257 |
+
parser.add_argument("--sample-size", type=int, default=150000, help="Number of models to process")
|
| 258 |
+
parser.add_argument("--output-dir", type=str, default="../precomputed_data", help="Output directory")
|
| 259 |
+
parser.add_argument("--version", type=str, default="v1", help="Version tag")
|
| 260 |
+
parser.add_argument("--pca-dims", type=int, default=50, help="PCA dimensions for pre-reduction")
|
| 261 |
+
parser.add_argument("--no-pca", action="store_true", help="Disable PCA pre-reduction")
|
| 262 |
+
|
| 263 |
+
args = parser.parse_args()
|
| 264 |
+
|
| 265 |
+
try:
|
| 266 |
+
precompute_fast(
|
| 267 |
+
sample_size=args.sample_size,
|
| 268 |
+
output_dir=args.output_dir,
|
| 269 |
+
version=args.version,
|
| 270 |
+
pca_dims=args.pca_dims,
|
| 271 |
+
use_pca=not args.no_pca
|
| 272 |
+
)
|
| 273 |
+
except Exception as e:
|
| 274 |
+
logger.error(f"Pre-computation failed: {e}", exc_info=True)
|
| 275 |
+
sys.exit(1)
|
| 276 |
+
|
backend/utils/cache.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Redis-based caching for API responses.
|
| 3 |
+
"""
|
| 4 |
+
import json
|
| 5 |
+
import hashlib
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Optional, Any
|
| 8 |
+
from functools import wraps
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import redis
|
| 12 |
+
import msgpack
|
| 13 |
+
REDIS_AVAILABLE = True
|
| 14 |
+
except ImportError:
|
| 15 |
+
REDIS_AVAILABLE = False
|
| 16 |
+
|
| 17 |
+
from core.config import settings
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ResponseCache:
|
| 23 |
+
"""Redis-based response cache with fallback to in-memory."""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
self.redis_client = None
|
| 27 |
+
self.memory_cache = {}
|
| 28 |
+
self.enabled = settings.REDIS_ENABLED and REDIS_AVAILABLE
|
| 29 |
+
|
| 30 |
+
if self.enabled:
|
| 31 |
+
try:
|
| 32 |
+
self.redis_client = redis.Redis(
|
| 33 |
+
host=settings.REDIS_HOST,
|
| 34 |
+
port=settings.REDIS_PORT,
|
| 35 |
+
db=0,
|
| 36 |
+
decode_responses=False, # We'll use msgpack
|
| 37 |
+
socket_timeout=2,
|
| 38 |
+
socket_connect_timeout=2
|
| 39 |
+
)
|
| 40 |
+
# Test connection
|
| 41 |
+
self.redis_client.ping()
|
| 42 |
+
logger.info(f"✅ Redis cache enabled at {settings.REDIS_HOST}:{settings.REDIS_PORT}")
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.warning(f"⚠️ Redis connection failed, using in-memory cache: {e}")
|
| 45 |
+
self.enabled = False
|
| 46 |
+
self.redis_client = None
|
| 47 |
+
else:
|
| 48 |
+
logger.info("📝 Using in-memory cache (Redis disabled)")
|
| 49 |
+
|
| 50 |
+
def _generate_key(self, prefix: str, *args, **kwargs) -> str:
|
| 51 |
+
"""Generate cache key from arguments."""
|
| 52 |
+
key_data = f"{prefix}:{args}:{sorted(kwargs.items())}"
|
| 53 |
+
return f"hfviz:{hashlib.md5(key_data.encode()).hexdigest()}"
|
| 54 |
+
|
| 55 |
+
def get(self, key: str) -> Optional[Any]:
|
| 56 |
+
"""Get value from cache."""
|
| 57 |
+
try:
|
| 58 |
+
if self.enabled and self.redis_client:
|
| 59 |
+
data = self.redis_client.get(key)
|
| 60 |
+
if data:
|
| 61 |
+
return msgpack.unpackb(data, raw=False)
|
| 62 |
+
else:
|
| 63 |
+
return self.memory_cache.get(key)
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.warning(f"Cache get error: {e}")
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
| 69 |
+
"""Set value in cache with TTL."""
|
| 70 |
+
try:
|
| 71 |
+
ttl = ttl or settings.REDIS_TTL
|
| 72 |
+
|
| 73 |
+
if self.enabled and self.redis_client:
|
| 74 |
+
packed_data = msgpack.packb(value, use_bin_type=True)
|
| 75 |
+
self.redis_client.setex(key, ttl, packed_data)
|
| 76 |
+
return True
|
| 77 |
+
else:
|
| 78 |
+
# In-memory cache with simple TTL tracking
|
| 79 |
+
self.memory_cache[key] = value
|
| 80 |
+
# Limit in-memory cache size
|
| 81 |
+
if len(self.memory_cache) > 100:
|
| 82 |
+
# Remove oldest entry
|
| 83 |
+
self.memory_cache.pop(next(iter(self.memory_cache)))
|
| 84 |
+
return True
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.warning(f"Cache set error: {e}")
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
def delete(self, key: str) -> bool:
|
| 90 |
+
"""Delete key from cache."""
|
| 91 |
+
try:
|
| 92 |
+
if self.enabled and self.redis_client:
|
| 93 |
+
self.redis_client.delete(key)
|
| 94 |
+
else:
|
| 95 |
+
self.memory_cache.pop(key, None)
|
| 96 |
+
return True
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.warning(f"Cache delete error: {e}")
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
def clear(self, pattern: str = "hfviz:*") -> bool:
|
| 102 |
+
"""Clear all cache keys matching pattern."""
|
| 103 |
+
try:
|
| 104 |
+
if self.enabled and self.redis_client:
|
| 105 |
+
keys = self.redis_client.keys(pattern)
|
| 106 |
+
if keys:
|
| 107 |
+
self.redis_client.delete(*keys)
|
| 108 |
+
else:
|
| 109 |
+
self.memory_cache.clear()
|
| 110 |
+
return True
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.warning(f"Cache clear error: {e}")
|
| 113 |
+
return False
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# Global cache instance
|
| 117 |
+
cache = ResponseCache()
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def cached_response(ttl: int = 300, key_prefix: str = "api"):
|
| 121 |
+
"""
|
| 122 |
+
Decorator for caching API responses.
|
| 123 |
+
|
| 124 |
+
Usage:
|
| 125 |
+
@cached_response(ttl=600, key_prefix="models")
|
| 126 |
+
async def get_models(...):
|
| 127 |
+
...
|
| 128 |
+
"""
|
| 129 |
+
def decorator(func):
|
| 130 |
+
@wraps(func)
|
| 131 |
+
async def wrapper(*args, **kwargs):
|
| 132 |
+
# Generate cache key from function args
|
| 133 |
+
cache_key = cache._generate_key(key_prefix, func.__name__, *args, **kwargs)
|
| 134 |
+
|
| 135 |
+
# Try to get from cache
|
| 136 |
+
cached_data = cache.get(cache_key)
|
| 137 |
+
if cached_data is not None:
|
| 138 |
+
logger.debug(f"Cache HIT: {cache_key[:20]}...")
|
| 139 |
+
return cached_data
|
| 140 |
+
|
| 141 |
+
# Execute function
|
| 142 |
+
logger.debug(f"Cache MISS: {cache_key[:20]}...")
|
| 143 |
+
result = await func(*args, **kwargs)
|
| 144 |
+
|
| 145 |
+
# Cache result
|
| 146 |
+
cache.set(cache_key, result, ttl=ttl)
|
| 147 |
+
|
| 148 |
+
return result
|
| 149 |
+
return wrapper
|
| 150 |
+
return decorator
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
|
backend/utils/data_loader.py
CHANGED
|
@@ -14,25 +14,118 @@ class ModelDataLoader:
|
|
| 14 |
self.dataset_name = dataset_name
|
| 15 |
self.df: Optional[pd.DataFrame] = None
|
| 16 |
|
| 17 |
-
def load_data(self, sample_size: Optional[int] = None, split: str = "train"
|
|
|
|
| 18 |
"""
|
| 19 |
-
Load dataset from Hugging Face Hub.
|
| 20 |
|
| 21 |
Args:
|
| 22 |
-
sample_size: If provided,
|
| 23 |
split: Dataset split to load
|
|
|
|
| 24 |
|
| 25 |
Returns:
|
| 26 |
DataFrame with model data
|
| 27 |
"""
|
| 28 |
dataset = load_dataset(self.dataset_name, split=split)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
def preprocess_for_embedding(self, df: Optional[pd.DataFrame] = None) -> pd.DataFrame:
|
| 38 |
"""
|
|
@@ -55,13 +148,17 @@ class ModelDataLoader:
|
|
| 55 |
if field in df.columns:
|
| 56 |
df[field] = df[field].fillna('')
|
| 57 |
|
|
|
|
| 58 |
df['combined_text'] = (
|
| 59 |
df.get('tags', '').astype(str) + ' ' +
|
| 60 |
df.get('pipeline_tag', '').astype(str) + ' ' +
|
| 61 |
-
df.get('library_name', '').astype(str)
|
| 62 |
-
df['modelCard'].astype(str).str[:500]
|
| 63 |
)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return df
|
| 66 |
|
| 67 |
def filter_data(
|
|
|
|
| 14 |
self.dataset_name = dataset_name
|
| 15 |
self.df: Optional[pd.DataFrame] = None
|
| 16 |
|
| 17 |
+
def load_data(self, sample_size: Optional[int] = None, split: str = "train",
|
| 18 |
+
prioritize_base_models: bool = True) -> pd.DataFrame:
|
| 19 |
"""
|
| 20 |
+
Load dataset from Hugging Face Hub with methodological sampling.
|
| 21 |
|
| 22 |
Args:
|
| 23 |
+
sample_size: If provided, sample this many rows using stratified approach
|
| 24 |
split: Dataset split to load
|
| 25 |
+
prioritize_base_models: If True, prioritize base models (no parent) in sampling
|
| 26 |
|
| 27 |
Returns:
|
| 28 |
DataFrame with model data
|
| 29 |
"""
|
| 30 |
dataset = load_dataset(self.dataset_name, split=split)
|
| 31 |
+
df_full = dataset.to_pandas()
|
| 32 |
+
|
| 33 |
+
if sample_size and len(df_full) > sample_size:
|
| 34 |
+
if prioritize_base_models:
|
| 35 |
+
# Methodological sampling: prioritize base models
|
| 36 |
+
df_full = self._stratified_sample(df_full, sample_size)
|
| 37 |
+
else:
|
| 38 |
+
# Random sampling (old approach)
|
| 39 |
+
dataset = dataset.shuffle(seed=42).select(range(sample_size))
|
| 40 |
+
df_full = dataset.to_pandas()
|
| 41 |
+
|
| 42 |
+
self.df = df_full
|
| 43 |
+
return self.df
|
| 44 |
+
|
| 45 |
+
def _stratified_sample(self, df: pd.DataFrame, sample_size: int) -> pd.DataFrame:
|
| 46 |
+
"""
|
| 47 |
+
Stratified sampling prioritizing base models and popular models.
|
| 48 |
|
| 49 |
+
Strategy:
|
| 50 |
+
1. Include ALL base models (no parent) if they fit in sample_size
|
| 51 |
+
2. Add popular models (high downloads/likes)
|
| 52 |
+
3. Fill remaining with diverse models across libraries/tasks
|
| 53 |
|
| 54 |
+
Args:
|
| 55 |
+
df: Full DataFrame
|
| 56 |
+
sample_size: Target sample size
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
Sampled DataFrame
|
| 60 |
+
"""
|
| 61 |
+
# Identify base models (no parent)
|
| 62 |
+
# parent_model is stored as string representation of list: '[]' for base models
|
| 63 |
+
base_models = df[
|
| 64 |
+
df['parent_model'].isna() |
|
| 65 |
+
(df['parent_model'] == '') |
|
| 66 |
+
(df['parent_model'] == '[]') |
|
| 67 |
+
(df['parent_model'] == 'null')
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
# Start with base models
|
| 71 |
+
if len(base_models) <= sample_size:
|
| 72 |
+
# All base models fit - include them all
|
| 73 |
+
sampled = base_models.copy()
|
| 74 |
+
remaining_size = sample_size - len(sampled)
|
| 75 |
+
|
| 76 |
+
# Get non-base models
|
| 77 |
+
non_base = df[~df.index.isin(sampled.index)]
|
| 78 |
+
|
| 79 |
+
if remaining_size > 0 and len(non_base) > 0:
|
| 80 |
+
# Add popular derived models and diverse samples
|
| 81 |
+
# Sort by downloads + likes for popularity
|
| 82 |
+
non_base['popularity_score'] = (
|
| 83 |
+
non_base.get('downloads', 0).fillna(0) +
|
| 84 |
+
non_base.get('likes', 0).fillna(0) * 100 # Weight likes more
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Take top 50% by popularity, 50% stratified by library
|
| 88 |
+
popular_size = min(remaining_size // 2, len(non_base))
|
| 89 |
+
diverse_size = remaining_size - popular_size
|
| 90 |
+
|
| 91 |
+
# Popular models
|
| 92 |
+
popular_models = non_base.nlargest(popular_size, 'popularity_score')
|
| 93 |
+
sampled = pd.concat([sampled, popular_models])
|
| 94 |
+
|
| 95 |
+
# Diverse sampling across libraries
|
| 96 |
+
if diverse_size > 0:
|
| 97 |
+
remaining = non_base[~non_base.index.isin(popular_models.index)]
|
| 98 |
+
if len(remaining) > 0:
|
| 99 |
+
# Stratify by library if possible
|
| 100 |
+
if 'library_name' in remaining.columns:
|
| 101 |
+
libraries = remaining['library_name'].value_counts()
|
| 102 |
+
diverse_samples = []
|
| 103 |
+
per_library = max(1, diverse_size // len(libraries))
|
| 104 |
+
|
| 105 |
+
for library in libraries.index:
|
| 106 |
+
lib_models = remaining[remaining['library_name'] == library]
|
| 107 |
+
n_sample = min(per_library, len(lib_models))
|
| 108 |
+
diverse_samples.append(lib_models.sample(n=n_sample, random_state=42))
|
| 109 |
+
|
| 110 |
+
diverse_df = pd.concat(diverse_samples).head(diverse_size)
|
| 111 |
+
else:
|
| 112 |
+
diverse_df = remaining.sample(n=min(diverse_size, len(remaining)), random_state=42)
|
| 113 |
+
|
| 114 |
+
sampled = pd.concat([sampled, diverse_df])
|
| 115 |
+
|
| 116 |
+
sampled = sampled.drop(columns=['popularity_score'], errors='ignore')
|
| 117 |
+
else:
|
| 118 |
+
# Too many base models - sample from them strategically
|
| 119 |
+
# Prioritize popular base models
|
| 120 |
+
base_models = base_models.copy() # Avoid SettingWithCopyWarning
|
| 121 |
+
base_models['popularity_score'] = (
|
| 122 |
+
base_models.get('downloads', 0).fillna(0) +
|
| 123 |
+
base_models.get('likes', 0).fillna(0) * 100
|
| 124 |
+
)
|
| 125 |
+
sampled = base_models.nlargest(sample_size, 'popularity_score')
|
| 126 |
+
sampled = sampled.drop(columns=['popularity_score'], errors='ignore')
|
| 127 |
+
|
| 128 |
+
return sampled.reset_index(drop=True)
|
| 129 |
|
| 130 |
def preprocess_for_embedding(self, df: Optional[pd.DataFrame] = None) -> pd.DataFrame:
|
| 131 |
"""
|
|
|
|
| 148 |
if field in df.columns:
|
| 149 |
df[field] = df[field].fillna('')
|
| 150 |
|
| 151 |
+
# Build combined text from available fields
|
| 152 |
df['combined_text'] = (
|
| 153 |
df.get('tags', '').astype(str) + ' ' +
|
| 154 |
df.get('pipeline_tag', '').astype(str) + ' ' +
|
| 155 |
+
df.get('library_name', '').astype(str)
|
|
|
|
| 156 |
)
|
| 157 |
|
| 158 |
+
# Add modelCard if available (only in withmodelcards dataset)
|
| 159 |
+
if 'modelCard' in df.columns:
|
| 160 |
+
df['combined_text'] = df['combined_text'] + ' ' + df['modelCard'].astype(str).str[:500]
|
| 161 |
+
|
| 162 |
return df
|
| 163 |
|
| 164 |
def filter_data(
|
backend/utils/precomputed_loader.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Loader for pre-computed embeddings and UMAP coordinates.
|
| 3 |
+
This module provides fast loading of pre-computed data from Parquet files.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional, Dict, Tuple
|
| 11 |
+
|
| 12 |
+
import pandas as pd
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PrecomputedDataLoader:
|
| 19 |
+
"""Load pre-computed embeddings and coordinates from Parquet files."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, data_dir: str = "precomputed_data", version: str = "v1"):
|
| 22 |
+
"""
|
| 23 |
+
Initialize the loader.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
data_dir: Directory containing pre-computed files
|
| 27 |
+
version: Version tag to load (default: v1)
|
| 28 |
+
"""
|
| 29 |
+
self.data_dir = Path(data_dir)
|
| 30 |
+
self.version = version
|
| 31 |
+
self.metadata = None
|
| 32 |
+
|
| 33 |
+
def load_metadata(self) -> Dict:
|
| 34 |
+
"""Load metadata about the pre-computed data."""
|
| 35 |
+
metadata_file = self.data_dir / f"metadata_{self.version}.json"
|
| 36 |
+
|
| 37 |
+
if not metadata_file.exists():
|
| 38 |
+
raise FileNotFoundError(
|
| 39 |
+
f"Metadata file not found: {metadata_file}\n"
|
| 40 |
+
f"Run scripts/precompute_data.py first to generate pre-computed data."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
with open(metadata_file, 'r') as f:
|
| 44 |
+
self.metadata = json.load(f)
|
| 45 |
+
|
| 46 |
+
logger.info(f"Loaded metadata for version {self.version}")
|
| 47 |
+
logger.info(f" Created: {self.metadata.get('created_at')}")
|
| 48 |
+
logger.info(f" Total models: {self.metadata.get('total_models'):,}")
|
| 49 |
+
logger.info(f" Embedding dim: {self.metadata.get('embedding_dim')}")
|
| 50 |
+
|
| 51 |
+
return self.metadata
|
| 52 |
+
|
| 53 |
+
def check_available(self) -> bool:
|
| 54 |
+
"""Check if pre-computed data is available."""
|
| 55 |
+
metadata_file = self.data_dir / f"metadata_{self.version}.json"
|
| 56 |
+
models_file = self.data_dir / f"models_{self.version}.parquet"
|
| 57 |
+
embeddings_file = self.data_dir / f"embeddings_{self.version}.parquet"
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
metadata_file.exists() and
|
| 61 |
+
models_file.exists() and
|
| 62 |
+
embeddings_file.exists()
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def load_models(self) -> pd.DataFrame:
|
| 66 |
+
"""
|
| 67 |
+
Load pre-computed model data with coordinates.
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
DataFrame with columns: model_id, library_name, pipeline_tag, downloads, likes,
|
| 71 |
+
x_3d, y_3d, z_3d, x_2d, y_2d, etc.
|
| 72 |
+
"""
|
| 73 |
+
models_file = self.data_dir / f"models_{self.version}.parquet"
|
| 74 |
+
|
| 75 |
+
if not models_file.exists():
|
| 76 |
+
raise FileNotFoundError(
|
| 77 |
+
f"Models file not found: {models_file}\n"
|
| 78 |
+
f"Run scripts/precompute_data.py first to generate pre-computed data."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
logger.info(f"Loading pre-computed models from {models_file}...")
|
| 82 |
+
df = pd.read_parquet(models_file)
|
| 83 |
+
|
| 84 |
+
# Set model_id as index
|
| 85 |
+
if 'model_id' in df.columns:
|
| 86 |
+
df.set_index('model_id', drop=False, inplace=True)
|
| 87 |
+
|
| 88 |
+
logger.info(f"Loaded {len(df):,} models with pre-computed coordinates")
|
| 89 |
+
|
| 90 |
+
return df
|
| 91 |
+
|
| 92 |
+
def load_embeddings(self) -> Tuple[np.ndarray, pd.Series]:
|
| 93 |
+
"""
|
| 94 |
+
Load pre-computed embeddings.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
Tuple of (embeddings_array, model_ids_series)
|
| 98 |
+
"""
|
| 99 |
+
embeddings_file = self.data_dir / f"embeddings_{self.version}.parquet"
|
| 100 |
+
|
| 101 |
+
if not embeddings_file.exists():
|
| 102 |
+
raise FileNotFoundError(
|
| 103 |
+
f"Embeddings file not found: {embeddings_file}\n"
|
| 104 |
+
f"Run scripts/precompute_data.py first to generate pre-computed data."
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
logger.info(f"Loading pre-computed embeddings from {embeddings_file}...")
|
| 108 |
+
df = pd.read_parquet(embeddings_file)
|
| 109 |
+
|
| 110 |
+
# Convert embeddings from list to numpy array
|
| 111 |
+
embeddings = np.array(df['embedding'].tolist())
|
| 112 |
+
model_ids = df['model_id']
|
| 113 |
+
|
| 114 |
+
logger.info(f"Loaded embeddings: {embeddings.shape}")
|
| 115 |
+
|
| 116 |
+
return embeddings, model_ids
|
| 117 |
+
|
| 118 |
+
def load_all(self) -> Tuple[pd.DataFrame, np.ndarray, Dict]:
|
| 119 |
+
"""
|
| 120 |
+
Load all pre-computed data.
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
Tuple of (models_df, embeddings_array, metadata_dict)
|
| 124 |
+
"""
|
| 125 |
+
metadata = self.load_metadata()
|
| 126 |
+
df = self.load_models()
|
| 127 |
+
embeddings, _ = self.load_embeddings()
|
| 128 |
+
|
| 129 |
+
return df, embeddings, metadata
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def get_precomputed_loader(
|
| 133 |
+
data_dir: Optional[str] = None,
|
| 134 |
+
version: str = "v1"
|
| 135 |
+
) -> Optional[PrecomputedDataLoader]:
|
| 136 |
+
"""
|
| 137 |
+
Get a PrecomputedDataLoader if pre-computed data is available.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
data_dir: Directory containing pre-computed files (default: auto-detect)
|
| 141 |
+
version: Version tag to load
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
PrecomputedDataLoader if available, None otherwise
|
| 145 |
+
"""
|
| 146 |
+
if data_dir is None:
|
| 147 |
+
# Try multiple locations
|
| 148 |
+
backend_dir = Path(__file__).parent.parent
|
| 149 |
+
root_dir = backend_dir.parent
|
| 150 |
+
|
| 151 |
+
possible_dirs = [
|
| 152 |
+
root_dir / "precomputed_data",
|
| 153 |
+
backend_dir / "precomputed_data",
|
| 154 |
+
Path("precomputed_data"),
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
for dir_path in possible_dirs:
|
| 158 |
+
if dir_path.exists():
|
| 159 |
+
loader = PrecomputedDataLoader(data_dir=str(dir_path), version=version)
|
| 160 |
+
if loader.check_available():
|
| 161 |
+
logger.info(f"Found pre-computed data in: {dir_path}")
|
| 162 |
+
return loader
|
| 163 |
+
|
| 164 |
+
return None
|
| 165 |
+
else:
|
| 166 |
+
loader = PrecomputedDataLoader(data_dir=data_dir, version=version)
|
| 167 |
+
if loader.check_available():
|
| 168 |
+
return loader
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
|
backend/utils/response_encoder.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fast response encoding with MessagePack and orjson.
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Any, List, Dict
|
| 6 |
+
from fastapi.responses import Response
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
import msgpack
|
| 10 |
+
MSGPACK_AVAILABLE = True
|
| 11 |
+
except ImportError:
|
| 12 |
+
MSGPACK_AVAILABLE = False
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
import orjson
|
| 16 |
+
ORJSON_AVAILABLE = True
|
| 17 |
+
except ImportError:
|
| 18 |
+
ORJSON_AVAILABLE = False
|
| 19 |
+
import json
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class FastJSONResponse(Response):
|
| 25 |
+
"""JSON response using orjson for 2-3x faster encoding."""
|
| 26 |
+
media_type = "application/json"
|
| 27 |
+
|
| 28 |
+
def render(self, content: Any) -> bytes:
|
| 29 |
+
if ORJSON_AVAILABLE:
|
| 30 |
+
return orjson.dumps(
|
| 31 |
+
content,
|
| 32 |
+
option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS
|
| 33 |
+
)
|
| 34 |
+
else:
|
| 35 |
+
return json.dumps(content, default=str).encode('utf-8')
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class MessagePackResponse(Response):
|
| 39 |
+
"""Binary response using MessagePack for 30-50% smaller payloads."""
|
| 40 |
+
media_type = "application/msgpack"
|
| 41 |
+
|
| 42 |
+
def render(self, content: Any) -> bytes:
|
| 43 |
+
if not MSGPACK_AVAILABLE:
|
| 44 |
+
raise RuntimeError("msgpack not installed")
|
| 45 |
+
return msgpack.packb(content, use_bin_type=True)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def encode_models_msgpack(models: List[Dict]) -> bytes:
|
| 49 |
+
"""
|
| 50 |
+
Encode model list to MessagePack binary format.
|
| 51 |
+
Optimized for the specific structure of ModelPoint objects.
|
| 52 |
+
"""
|
| 53 |
+
if not MSGPACK_AVAILABLE:
|
| 54 |
+
raise RuntimeError("msgpack not installed")
|
| 55 |
+
|
| 56 |
+
# Convert to more compact format
|
| 57 |
+
compact_models = []
|
| 58 |
+
for model in models:
|
| 59 |
+
compact_models.append({
|
| 60 |
+
'id': model.get('model_id'),
|
| 61 |
+
'x': model.get('x'),
|
| 62 |
+
'y': model.get('y'),
|
| 63 |
+
'z': model.get('z', 0),
|
| 64 |
+
'lib': model.get('library_name'),
|
| 65 |
+
'pipe': model.get('pipeline_tag'),
|
| 66 |
+
'dl': model.get('downloads', 0),
|
| 67 |
+
'l': model.get('likes', 0),
|
| 68 |
+
'ts': model.get('trending_score'),
|
| 69 |
+
'par': model.get('parent_model'),
|
| 70 |
+
'lic': model.get('licenses'),
|
| 71 |
+
'fd': model.get('family_depth'),
|
| 72 |
+
'cid': model.get('cluster_id'),
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
return msgpack.packb(compact_models, use_bin_type=True)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def decode_models_msgpack(data: bytes) -> List[Dict]:
|
| 79 |
+
"""Decode MessagePack binary to model list."""
|
| 80 |
+
if not MSGPACK_AVAILABLE:
|
| 81 |
+
raise RuntimeError("msgpack not installed")
|
| 82 |
+
|
| 83 |
+
compact_models = msgpack.unpackb(data, raw=False)
|
| 84 |
+
|
| 85 |
+
# Expand back to full format
|
| 86 |
+
models = []
|
| 87 |
+
for cm in compact_models:
|
| 88 |
+
models.append({
|
| 89 |
+
'model_id': cm.get('id'),
|
| 90 |
+
'x': cm.get('x'),
|
| 91 |
+
'y': cm.get('y'),
|
| 92 |
+
'z': cm.get('z', 0),
|
| 93 |
+
'library_name': cm.get('lib'),
|
| 94 |
+
'pipeline_tag': cm.get('pipe'),
|
| 95 |
+
'downloads': cm.get('dl', 0),
|
| 96 |
+
'likes': cm.get('l', 0),
|
| 97 |
+
'trending_score': cm.get('ts'),
|
| 98 |
+
'parent_model': cm.get('par'),
|
| 99 |
+
'licenses': cm.get('lic'),
|
| 100 |
+
'family_depth': cm.get('fd'),
|
| 101 |
+
'cluster_id': cm.get('cid'),
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
return models
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
|
backend_full.log
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
INFO: Started server process [31410]
|
| 2 |
+
INFO: Waiting for application startup.
|
| 3 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 4 |
+
INFO: Application startup complete.
|
| 5 |
+
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
| 6 |
+
INFO: 127.0.0.1:55718 - "GET /api/stats HTTP/1.1" 200 OK
|
| 7 |
+
Unhandled exception
|
| 8 |
+
Traceback (most recent call last):
|
| 9 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
| 10 |
+
await self.app(scope, receive, _send)
|
| 11 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/cors.py", line 85, in __call__
|
| 12 |
+
await self.app(scope, receive, send)
|
| 13 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/gzip.py", line 29, in __call__
|
| 14 |
+
await responder(scope, receive, send)
|
| 15 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/gzip.py", line 46, in __call__
|
| 16 |
+
await self.app(scope, receive, self.send_with_compression)
|
| 17 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
|
| 18 |
+
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
| 19 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
| 20 |
+
raise exc
|
| 21 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
| 22 |
+
await app(scope, receive, sender)
|
| 23 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
|
| 24 |
+
await self.app(scope, receive, send)
|
| 25 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/routing.py", line 716, in __call__
|
| 26 |
+
await self.middleware_stack(scope, receive, send)
|
| 27 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/routing.py", line 736, in app
|
| 28 |
+
await route.handle(scope, receive, send)
|
| 29 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/routing.py", line 290, in handle
|
| 30 |
+
await self.app(scope, receive, send)
|
| 31 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 125, in app
|
| 32 |
+
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
| 33 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
| 34 |
+
raise exc
|
| 35 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
| 36 |
+
await app(scope, receive, sender)
|
| 37 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 111, in app
|
| 38 |
+
response = await f(request)
|
| 39 |
+
^^^^^^^^^^^^^^^^
|
| 40 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 391, in app
|
| 41 |
+
raw_response = await run_endpoint_function(
|
| 42 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 43 |
+
...<3 lines>...
|
| 44 |
+
)
|
| 45 |
+
^
|
| 46 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 290, in run_endpoint_function
|
| 47 |
+
return await dependant.call(**values)
|
| 48 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 49 |
+
File "/Users/hamidaho/hf_viz/backend/api/routes/models.py", line 196, in get_models
|
| 50 |
+
filtered_reduced = current_reduced[filtered_indices]
|
| 51 |
+
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
|
| 52 |
+
IndexError: index 5000 is out of bounds for axis 0 with size 5000
|
| 53 |
+
INFO: 127.0.0.1:55722 - "GET /api/models?limit=1 HTTP/1.1" 500 Internal Server Error
|
| 54 |
+
ERROR: Exception in ASGI application
|
| 55 |
+
Traceback (most recent call last):
|
| 56 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/uvicorn/protocols/http/httptools_impl.py", line 409, in run_asgi
|
| 57 |
+
result = await app( # type: ignore[func-returns-value]
|
| 58 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 59 |
+
self.scope, self.receive, self.send
|
| 60 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 61 |
+
)
|
| 62 |
+
^
|
| 63 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__
|
| 64 |
+
return await self.app(scope, receive, send)
|
| 65 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 66 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/applications.py", line 1134, in __call__
|
| 67 |
+
await super().__call__(scope, receive, send)
|
| 68 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/applications.py", line 113, in __call__
|
| 69 |
+
await self.middleware_stack(scope, receive, send)
|
| 70 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
| 71 |
+
raise exc
|
| 72 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
| 73 |
+
await self.app(scope, receive, _send)
|
| 74 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/cors.py", line 85, in __call__
|
| 75 |
+
await self.app(scope, receive, send)
|
| 76 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/gzip.py", line 29, in __call__
|
| 77 |
+
await responder(scope, receive, send)
|
| 78 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/gzip.py", line 46, in __call__
|
| 79 |
+
await self.app(scope, receive, self.send_with_compression)
|
| 80 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
|
| 81 |
+
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
| 82 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
| 83 |
+
raise exc
|
| 84 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
| 85 |
+
await app(scope, receive, sender)
|
| 86 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
|
| 87 |
+
await self.app(scope, receive, send)
|
| 88 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/routing.py", line 716, in __call__
|
| 89 |
+
await self.middleware_stack(scope, receive, send)
|
| 90 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/routing.py", line 736, in app
|
| 91 |
+
await route.handle(scope, receive, send)
|
| 92 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/routing.py", line 290, in handle
|
| 93 |
+
await self.app(scope, receive, send)
|
| 94 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 125, in app
|
| 95 |
+
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
| 96 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
| 97 |
+
raise exc
|
| 98 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
| 99 |
+
await app(scope, receive, sender)
|
| 100 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 111, in app
|
| 101 |
+
response = await f(request)
|
| 102 |
+
^^^^^^^^^^^^^^^^
|
| 103 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 391, in app
|
| 104 |
+
raw_response = await run_endpoint_function(
|
| 105 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 106 |
+
...<3 lines>...
|
| 107 |
+
)
|
| 108 |
+
^
|
| 109 |
+
File "/Users/hamidaho/hf_viz/venv/lib/python3.13/site-packages/fastapi/routing.py", line 290, in run_endpoint_function
|
| 110 |
+
return await dependant.call(**values)
|
| 111 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 112 |
+
File "/Users/hamidaho/hf_viz/backend/api/routes/models.py", line 196, in get_models
|
| 113 |
+
filtered_reduced = current_reduced[filtered_indices]
|
| 114 |
+
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
|
| 115 |
+
IndexError: index 5000 is out of bounds for axis 0 with size 5000
|
| 116 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 117 |
+
INFO: 127.0.0.1:55737 - "GET /api/model-count/current?use_models_page=true&use_dataset_snapshot=true&use_cache=true HTTP/1.1" 200 OK
|
| 118 |
+
/opt/homebrew/Cellar/[email protected]/3.13.7/Frameworks/Python.framework/Versions/3.13/lib/python3.13/multiprocessing/resource_tracker.py:324: UserWarning: resource_tracker: There appear to be 1 leaked semaphore objects to clean up at shutdown: {'/loky-31410-uhk6grf9'}
|
| 119 |
+
warnings.warn(
|
backend_full_processing.log
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
INFO: Started server process [37417]
|
| 2 |
+
INFO: Waiting for application startup.
|
| 3 |
+
Repo card metadata block was not found. Setting CardData to empty.
|
| 4 |
+
|
| 5 |
+
warnings.warn(
|
deploy/backend-Procfile
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
-
web:
|
| 2 |
|
|
|
|
| 1 |
+
web: cd backend && uvicorn api.main:app --host 0.0.0.0 --port $PORT
|
| 2 |
|
deploy/fly.toml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fly.io deployment configuration
|
| 2 |
+
app = "hf-viz-backend"
|
| 3 |
+
primary_region = "sjc" # San Jose, change to your preferred region
|
| 4 |
+
|
| 5 |
+
[build]
|
| 6 |
+
dockerfile = "Dockerfile"
|
| 7 |
+
|
| 8 |
+
[env]
|
| 9 |
+
PORT = "8000"
|
| 10 |
+
SAMPLE_SIZE = "5000"
|
| 11 |
+
|
| 12 |
+
[http_service]
|
| 13 |
+
internal_port = 8000
|
| 14 |
+
force_https = true
|
| 15 |
+
auto_stop_machines = true
|
| 16 |
+
auto_start_machines = true
|
| 17 |
+
min_machines_running = 0 # Scale to zero when idle
|
| 18 |
+
|
| 19 |
+
[[http_service.checks]]
|
| 20 |
+
grace_period = "10s"
|
| 21 |
+
interval = "30s"
|
| 22 |
+
method = "GET"
|
| 23 |
+
timeout = "5s"
|
| 24 |
+
path = "/docs"
|
| 25 |
+
|
| 26 |
+
[[vm]]
|
| 27 |
+
memory = "512mb"
|
| 28 |
+
cpu_kind = "shared"
|
| 29 |
+
cpus = 1
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
|
deploy/nginx.conf
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nginx reverse proxy configuration for HF Viz
|
| 2 |
+
# Provides caching, compression, and load balancing
|
| 3 |
+
|
| 4 |
+
upstream hfviz_backend {
|
| 5 |
+
server localhost:8000;
|
| 6 |
+
# Add more backend servers for load balancing
|
| 7 |
+
# server localhost:8001;
|
| 8 |
+
# server localhost:8002;
|
| 9 |
+
keepalive 32;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
# Cache configuration
|
| 13 |
+
proxy_cache_path /var/cache/nginx/hfviz
|
| 14 |
+
levels=1:2
|
| 15 |
+
keys_zone=hfviz_cache:100m
|
| 16 |
+
max_size=10g
|
| 17 |
+
inactive=60m
|
| 18 |
+
use_temp_path=off;
|
| 19 |
+
|
| 20 |
+
server {
|
| 21 |
+
listen 80;
|
| 22 |
+
listen [::]:80;
|
| 23 |
+
server_name api.hfviz.example.com; # Change to your domain
|
| 24 |
+
|
| 25 |
+
# Gzip compression
|
| 26 |
+
gzip on;
|
| 27 |
+
gzip_vary on;
|
| 28 |
+
gzip_min_length 1000;
|
| 29 |
+
gzip_types
|
| 30 |
+
text/plain
|
| 31 |
+
text/css
|
| 32 |
+
text/xml
|
| 33 |
+
text/javascript
|
| 34 |
+
application/json
|
| 35 |
+
application/javascript
|
| 36 |
+
application/xml+rss
|
| 37 |
+
application/msgpack;
|
| 38 |
+
|
| 39 |
+
# Security headers
|
| 40 |
+
add_header X-Content-Type-Options "nosniff" always;
|
| 41 |
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
| 42 |
+
add_header X-XSS-Protection "1; mode=block" always;
|
| 43 |
+
|
| 44 |
+
# CORS headers (if needed)
|
| 45 |
+
add_header Access-Control-Allow-Origin "*" always;
|
| 46 |
+
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
| 47 |
+
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization" always;
|
| 48 |
+
add_header Access-Control-Expose-Headers "Cache-Control, Content-Type" always;
|
| 49 |
+
|
| 50 |
+
# Handle preflight requests
|
| 51 |
+
if ($request_method = OPTIONS) {
|
| 52 |
+
return 204;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# Proxy settings
|
| 56 |
+
proxy_http_version 1.1;
|
| 57 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 58 |
+
proxy_set_header Connection "upgrade";
|
| 59 |
+
proxy_set_header Host $host;
|
| 60 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 61 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 62 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 63 |
+
|
| 64 |
+
# Cache static API responses (GET only)
|
| 65 |
+
location /api/models {
|
| 66 |
+
proxy_pass http://hfviz_backend;
|
| 67 |
+
|
| 68 |
+
# Enable caching for this endpoint
|
| 69 |
+
proxy_cache hfviz_cache;
|
| 70 |
+
proxy_cache_key "$request_method$request_uri";
|
| 71 |
+
proxy_cache_valid 200 5m;
|
| 72 |
+
proxy_cache_valid 404 1m;
|
| 73 |
+
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
| 74 |
+
proxy_cache_background_update on;
|
| 75 |
+
proxy_cache_lock on;
|
| 76 |
+
|
| 77 |
+
# Show cache status in response header
|
| 78 |
+
add_header X-Cache-Status $upstream_cache_status;
|
| 79 |
+
|
| 80 |
+
# Bypass cache for authenticated requests
|
| 81 |
+
proxy_cache_bypass $http_authorization;
|
| 82 |
+
proxy_no_cache $http_authorization;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
location /api/stats {
|
| 86 |
+
proxy_pass http://hfviz_backend;
|
| 87 |
+
|
| 88 |
+
proxy_cache hfviz_cache;
|
| 89 |
+
proxy_cache_valid 200 10m;
|
| 90 |
+
add_header X-Cache-Status $upstream_cache_status;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# No caching for search/dynamic endpoints
|
| 94 |
+
location /api/search {
|
| 95 |
+
proxy_pass http://hfviz_backend;
|
| 96 |
+
proxy_cache off;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
location /api/model/ {
|
| 100 |
+
proxy_pass http://hfviz_backend;
|
| 101 |
+
|
| 102 |
+
# Light caching for model details
|
| 103 |
+
proxy_cache hfviz_cache;
|
| 104 |
+
proxy_cache_valid 200 10m;
|
| 105 |
+
add_header X-Cache-Status $upstream_cache_status;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# No caching for write operations
|
| 109 |
+
location ~ ^/api/.*/record$ {
|
| 110 |
+
proxy_pass http://hfviz_backend;
|
| 111 |
+
proxy_cache off;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# Default proxy for all other API endpoints
|
| 115 |
+
location /api/ {
|
| 116 |
+
proxy_pass http://hfviz_backend;
|
| 117 |
+
|
| 118 |
+
# Timeouts for slow operations
|
| 119 |
+
proxy_connect_timeout 60s;
|
| 120 |
+
proxy_send_timeout 60s;
|
| 121 |
+
proxy_read_timeout 60s;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# Health check endpoint
|
| 125 |
+
location /health {
|
| 126 |
+
proxy_pass http://hfviz_backend/;
|
| 127 |
+
access_log off;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
# API docs
|
| 131 |
+
location /docs {
|
| 132 |
+
proxy_pass http://hfviz_backend/docs;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
# Cache purge endpoint (restrict to localhost)
|
| 136 |
+
location /purge {
|
| 137 |
+
allow 127.0.0.1;
|
| 138 |
+
deny all;
|
| 139 |
+
proxy_cache_purge hfviz_cache "$request_method$request_uri";
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
# SSL configuration (if using HTTPS)
|
| 144 |
+
# server {
|
| 145 |
+
# listen 443 ssl http2;
|
| 146 |
+
# listen [::]:443 ssl http2;
|
| 147 |
+
# server_name api.hfviz.example.com;
|
| 148 |
+
#
|
| 149 |
+
# ssl_certificate /path/to/cert.pem;
|
| 150 |
+
# ssl_certificate_key /path/to/key.pem;
|
| 151 |
+
# ssl_protocols TLSv1.2 TLSv1.3;
|
| 152 |
+
# ssl_ciphers HIGH:!aNULL:!MD5;
|
| 153 |
+
#
|
| 154 |
+
# # ... (same location blocks as above)
|
| 155 |
+
# }
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
|
deploy/railway.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
| 2 |
"$schema": "https://railway.app/railway.schema.json",
|
| 3 |
"build": {
|
| 4 |
"builder": "NIXPACKS",
|
| 5 |
-
"buildCommand": "pip install -r requirements.txt
|
| 6 |
},
|
| 7 |
"deploy": {
|
| 8 |
-
"startCommand": "cd backend &&
|
| 9 |
"restartPolicyType": "ON_FAILURE",
|
| 10 |
"restartPolicyMaxRetries": 10
|
| 11 |
}
|
|
|
|
| 2 |
"$schema": "https://railway.app/railway.schema.json",
|
| 3 |
"build": {
|
| 4 |
"builder": "NIXPACKS",
|
| 5 |
+
"buildCommand": "pip install -r requirements.txt"
|
| 6 |
},
|
| 7 |
"deploy": {
|
| 8 |
+
"startCommand": "cd backend && uvicorn api.main:app --host 0.0.0.0 --port $PORT",
|
| 9 |
"restartPolicyType": "ON_FAILURE",
|
| 10 |
"restartPolicyMaxRetries": 10
|
| 11 |
}
|
deploy/render.yaml
CHANGED
|
@@ -2,14 +2,16 @@ services:
|
|
| 2 |
- type: web
|
| 3 |
name: hf-viz-backend
|
| 4 |
env: python
|
| 5 |
-
buildCommand: pip install -r
|
| 6 |
-
startCommand: cd backend &&
|
| 7 |
envVars:
|
| 8 |
- key: PORT
|
| 9 |
value: 8000
|
|
|
|
|
|
|
| 10 |
- key: FRONTEND_URL
|
| 11 |
sync: false # Set this to your Netlify frontend URL
|
| 12 |
plan: starter # Adjust based on your needs (starter, standard, pro)
|
| 13 |
region: oregon # Choose your preferred region
|
| 14 |
-
healthCheckPath: /
|
| 15 |
|
|
|
|
| 2 |
- type: web
|
| 3 |
name: hf-viz-backend
|
| 4 |
env: python
|
| 5 |
+
buildCommand: pip install -r requirements.txt
|
| 6 |
+
startCommand: cd backend && uvicorn api.main:app --host 0.0.0.0 --port $PORT
|
| 7 |
envVars:
|
| 8 |
- key: PORT
|
| 9 |
value: 8000
|
| 10 |
+
- key: SAMPLE_SIZE
|
| 11 |
+
value: 5000
|
| 12 |
- key: FRONTEND_URL
|
| 13 |
sync: false # Set this to your Netlify frontend URL
|
| 14 |
plan: starter # Adjust based on your needs (starter, standard, pro)
|
| 15 |
region: oregon # Choose your preferred region
|
| 16 |
+
healthCheckPath: /docs
|
| 17 |
|
deploy_railway.sh
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Quick deployment script for Railway
|
| 3 |
+
|
| 4 |
+
echo "🚂 Deploying HF Viz Backend to Railway..."
|
| 5 |
+
echo ""
|
| 6 |
+
|
| 7 |
+
# Check if Railway CLI is installed
|
| 8 |
+
if ! command -v railway &> /dev/null; then
|
| 9 |
+
echo "❌ Railway CLI not found. Installing..."
|
| 10 |
+
npm install -g @railway/cli
|
| 11 |
+
fi
|
| 12 |
+
|
| 13 |
+
# Login to Railway
|
| 14 |
+
echo "📝 Please login to Railway..."
|
| 15 |
+
railway login
|
| 16 |
+
|
| 17 |
+
# Initialize project if needed
|
| 18 |
+
if [ ! -f "railway.json" ]; then
|
| 19 |
+
echo "🎬 Initializing Railway project..."
|
| 20 |
+
railway init
|
| 21 |
+
fi
|
| 22 |
+
|
| 23 |
+
# Deploy
|
| 24 |
+
echo "🚀 Deploying..."
|
| 25 |
+
railway up
|
| 26 |
+
|
| 27 |
+
# Set environment variables
|
| 28 |
+
echo "⚙️ Setting environment variables..."
|
| 29 |
+
railway variables set SAMPLE_SIZE=5000
|
| 30 |
+
railway variables set PORT=8000
|
| 31 |
+
|
| 32 |
+
# Generate domain
|
| 33 |
+
echo "🌐 Setting up domain..."
|
| 34 |
+
railway domain
|
| 35 |
+
|
| 36 |
+
# Get the URL
|
| 37 |
+
echo ""
|
| 38 |
+
echo "✅ Deployment complete!"
|
| 39 |
+
echo ""
|
| 40 |
+
echo "📋 Next steps:"
|
| 41 |
+
echo "1. Copy the URL shown above"
|
| 42 |
+
echo "2. Update frontend/src/config/api.ts with this URL"
|
| 43 |
+
echo "3. Redeploy frontend to Netlify"
|
| 44 |
+
echo ""
|
| 45 |
+
echo "🔍 Check logs with: railway logs"
|
| 46 |
+
echo "🌐 Open dashboard: railway open"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker Compose configuration for HF Viz with Redis
|
| 2 |
+
|
| 3 |
+
version: '3.8'
|
| 4 |
+
|
| 5 |
+
services:
|
| 6 |
+
redis:
|
| 7 |
+
image: redis:7-alpine
|
| 8 |
+
container_name: hfviz-redis
|
| 9 |
+
restart: unless-stopped
|
| 10 |
+
ports:
|
| 11 |
+
- "6379:6379"
|
| 12 |
+
volumes:
|
| 13 |
+
- redis_data:/data
|
| 14 |
+
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
|
| 15 |
+
healthcheck:
|
| 16 |
+
test: ["CMD", "redis-cli", "ping"]
|
| 17 |
+
interval: 10s
|
| 18 |
+
timeout: 3s
|
| 19 |
+
retries: 3
|
| 20 |
+
|
| 21 |
+
backend:
|
| 22 |
+
build:
|
| 23 |
+
context: ..
|
| 24 |
+
dockerfile: Dockerfile
|
| 25 |
+
container_name: hfviz-backend
|
| 26 |
+
restart: unless-stopped
|
| 27 |
+
ports:
|
| 28 |
+
- "8000:8000"
|
| 29 |
+
environment:
|
| 30 |
+
- PORT=8000
|
| 31 |
+
- SAMPLE_SIZE=5000
|
| 32 |
+
- REDIS_ENABLED=true
|
| 33 |
+
- REDIS_HOST=redis
|
| 34 |
+
- REDIS_PORT=6379
|
| 35 |
+
- REDIS_TTL=300
|
| 36 |
+
- FRONTEND_URL=http://localhost:3000
|
| 37 |
+
depends_on:
|
| 38 |
+
redis:
|
| 39 |
+
condition: service_healthy
|
| 40 |
+
volumes:
|
| 41 |
+
- ../cache:/app/cache
|
| 42 |
+
- ../precomputed_data:/app/precomputed_data
|
| 43 |
+
healthcheck:
|
| 44 |
+
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
|
| 45 |
+
interval: 30s
|
| 46 |
+
timeout: 10s
|
| 47 |
+
retries: 3
|
| 48 |
+
|
| 49 |
+
nginx:
|
| 50 |
+
image: nginx:alpine
|
| 51 |
+
container_name: hfviz-nginx
|
| 52 |
+
restart: unless-stopped
|
| 53 |
+
ports:
|
| 54 |
+
- "80:80"
|
| 55 |
+
volumes:
|
| 56 |
+
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
| 57 |
+
- nginx_cache:/var/cache/nginx
|
| 58 |
+
depends_on:
|
| 59 |
+
- backend
|
| 60 |
+
healthcheck:
|
| 61 |
+
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
|
| 62 |
+
interval: 10s
|
| 63 |
+
timeout: 3s
|
| 64 |
+
retries: 3
|
| 65 |
+
|
| 66 |
+
volumes:
|
| 67 |
+
redis_data:
|
| 68 |
+
driver: local
|
| 69 |
+
nginx_cache:
|
| 70 |
+
driver: local
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
frontend/package-lock.json
CHANGED
|
@@ -27,13 +27,23 @@
|
|
| 27 |
"@visx/visx": "^3.0.0",
|
| 28 |
"ajv": "^8.17.1",
|
| 29 |
"axios": "^1.6.0",
|
|
|
|
| 30 |
"d3": "^7.8.5",
|
|
|
|
|
|
|
| 31 |
"react": "^18.2.0",
|
| 32 |
"react-dom": "^18.2.0",
|
| 33 |
"react-scripts": "5.0.1",
|
|
|
|
|
|
|
| 34 |
"three": "^0.160.1",
|
| 35 |
"typescript": "^5.0.0",
|
| 36 |
"zustand": "^5.0.8"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
},
|
| 39 |
"node_modules/@alloc/quick-lru": {
|
|
@@ -4225,6 +4235,16 @@
|
|
| 4225 |
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
| 4226 |
"license": "MIT"
|
| 4227 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4228 |
"node_modules/@types/node": {
|
| 4229 |
"version": "24.10.1",
|
| 4230 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
|
@@ -4313,6 +4333,26 @@
|
|
| 4313 |
"@types/react": "*"
|
| 4314 |
}
|
| 4315 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4316 |
"node_modules/@types/resolve": {
|
| 4317 |
"version": "1.17.1",
|
| 4318 |
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
|
@@ -7160,6 +7200,12 @@
|
|
| 7160 |
"node": ">= 0.8"
|
| 7161 |
}
|
| 7162 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7163 |
"node_modules/commander": {
|
| 7164 |
"version": "7.2.0",
|
| 7165 |
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
|
@@ -9681,6 +9727,12 @@
|
|
| 9681 |
"node": ">= 0.6"
|
| 9682 |
}
|
| 9683 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9684 |
"node_modules/eventemitter3": {
|
| 9685 |
"version": "4.0.7",
|
| 9686 |
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
|
@@ -11126,9 +11178,9 @@
|
|
| 11126 |
}
|
| 11127 |
},
|
| 11128 |
"node_modules/idb": {
|
| 11129 |
-
"version": "
|
| 11130 |
-
"resolved": "https://registry.npmjs.org/idb/-/idb-
|
| 11131 |
-
"integrity": "sha512-
|
| 11132 |
"license": "ISC"
|
| 11133 |
},
|
| 11134 |
"node_modules/identity-obj-proxy": {
|
|
@@ -11264,6 +11316,12 @@
|
|
| 11264 |
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
| 11265 |
"license": "ISC"
|
| 11266 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11267 |
"node_modules/internal-slot": {
|
| 11268 |
"version": "1.1.0",
|
| 11269 |
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
|
@@ -13400,6 +13458,12 @@
|
|
| 13400 |
"node": ">= 4.0.0"
|
| 13401 |
}
|
| 13402 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13403 |
"node_modules/merge-descriptors": {
|
| 13404 |
"version": "1.0.3",
|
| 13405 |
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
|
@@ -13583,6 +13647,27 @@
|
|
| 13583 |
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 13584 |
"license": "MIT"
|
| 13585 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13586 |
"node_modules/multicast-dns": {
|
| 13587 |
"version": "7.2.5",
|
| 13588 |
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
|
@@ -16168,6 +16253,33 @@
|
|
| 16168 |
}
|
| 16169 |
}
|
| 16170 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16171 |
"node_modules/read-cache": {
|
| 16172 |
"version": "1.0.0",
|
| 16173 |
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
|
@@ -19382,6 +19494,12 @@
|
|
| 19382 |
"workbox-core": "6.6.0"
|
| 19383 |
}
|
| 19384 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19385 |
"node_modules/workbox-broadcast-update": {
|
| 19386 |
"version": "6.6.0",
|
| 19387 |
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.6.0.tgz",
|
|
@@ -19519,6 +19637,12 @@
|
|
| 19519 |
"workbox-core": "6.6.0"
|
| 19520 |
}
|
| 19521 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19522 |
"node_modules/workbox-google-analytics": {
|
| 19523 |
"version": "6.6.0",
|
| 19524 |
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.6.0.tgz",
|
|
|
|
| 27 |
"@visx/visx": "^3.0.0",
|
| 28 |
"ajv": "^8.17.1",
|
| 29 |
"axios": "^1.6.0",
|
| 30 |
+
"comlink": "^4.4.1",
|
| 31 |
"d3": "^7.8.5",
|
| 32 |
+
"idb": "^8.0.0",
|
| 33 |
+
"msgpack-lite": "^0.1.26",
|
| 34 |
"react": "^18.2.0",
|
| 35 |
"react-dom": "^18.2.0",
|
| 36 |
"react-scripts": "5.0.1",
|
| 37 |
+
"react-virtualized-auto-sizer": "^1.0.24",
|
| 38 |
+
"react-window": "^1.8.10",
|
| 39 |
"three": "^0.160.1",
|
| 40 |
"typescript": "^5.0.0",
|
| 41 |
"zustand": "^5.0.8"
|
| 42 |
+
},
|
| 43 |
+
"devDependencies": {
|
| 44 |
+
"@types/msgpack-lite": "^0.1.11",
|
| 45 |
+
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
| 46 |
+
"@types/react-window": "^1.8.8"
|
| 47 |
}
|
| 48 |
},
|
| 49 |
"node_modules/@alloc/quick-lru": {
|
|
|
|
| 4235 |
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
| 4236 |
"license": "MIT"
|
| 4237 |
},
|
| 4238 |
+
"node_modules/@types/msgpack-lite": {
|
| 4239 |
+
"version": "0.1.12",
|
| 4240 |
+
"resolved": "https://registry.npmjs.org/@types/msgpack-lite/-/msgpack-lite-0.1.12.tgz",
|
| 4241 |
+
"integrity": "sha512-DzYHfFOxK1UCm3pErFyGCQzsNYjcy1chCtO5bLdvL0IbD6L0l0IZzYMHvFAmEDLJ+k6XJosY0WKKmjkOBu9gKQ==",
|
| 4242 |
+
"dev": true,
|
| 4243 |
+
"license": "MIT",
|
| 4244 |
+
"dependencies": {
|
| 4245 |
+
"@types/node": "*"
|
| 4246 |
+
}
|
| 4247 |
+
},
|
| 4248 |
"node_modules/@types/node": {
|
| 4249 |
"version": "24.10.1",
|
| 4250 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
|
|
|
| 4333 |
"@types/react": "*"
|
| 4334 |
}
|
| 4335 |
},
|
| 4336 |
+
"node_modules/@types/react-virtualized-auto-sizer": {
|
| 4337 |
+
"version": "1.0.4",
|
| 4338 |
+
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz",
|
| 4339 |
+
"integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==",
|
| 4340 |
+
"dev": true,
|
| 4341 |
+
"license": "MIT",
|
| 4342 |
+
"dependencies": {
|
| 4343 |
+
"@types/react": "*"
|
| 4344 |
+
}
|
| 4345 |
+
},
|
| 4346 |
+
"node_modules/@types/react-window": {
|
| 4347 |
+
"version": "1.8.8",
|
| 4348 |
+
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
| 4349 |
+
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
| 4350 |
+
"dev": true,
|
| 4351 |
+
"license": "MIT",
|
| 4352 |
+
"dependencies": {
|
| 4353 |
+
"@types/react": "*"
|
| 4354 |
+
}
|
| 4355 |
+
},
|
| 4356 |
"node_modules/@types/resolve": {
|
| 4357 |
"version": "1.17.1",
|
| 4358 |
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
|
|
|
| 7200 |
"node": ">= 0.8"
|
| 7201 |
}
|
| 7202 |
},
|
| 7203 |
+
"node_modules/comlink": {
|
| 7204 |
+
"version": "4.4.2",
|
| 7205 |
+
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
|
| 7206 |
+
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
|
| 7207 |
+
"license": "Apache-2.0"
|
| 7208 |
+
},
|
| 7209 |
"node_modules/commander": {
|
| 7210 |
"version": "7.2.0",
|
| 7211 |
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
|
|
|
| 9727 |
"node": ">= 0.6"
|
| 9728 |
}
|
| 9729 |
},
|
| 9730 |
+
"node_modules/event-lite": {
|
| 9731 |
+
"version": "0.1.3",
|
| 9732 |
+
"resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.3.tgz",
|
| 9733 |
+
"integrity": "sha512-8qz9nOz5VeD2z96elrEKD2U433+L3DWdUdDkOINLGOJvx1GsMBbMn0aCeu28y8/e85A6mCigBiFlYMnTBEGlSw==",
|
| 9734 |
+
"license": "MIT"
|
| 9735 |
+
},
|
| 9736 |
"node_modules/eventemitter3": {
|
| 9737 |
"version": "4.0.7",
|
| 9738 |
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
|
|
|
| 11178 |
}
|
| 11179 |
},
|
| 11180 |
"node_modules/idb": {
|
| 11181 |
+
"version": "8.0.3",
|
| 11182 |
+
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
| 11183 |
+
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
| 11184 |
"license": "ISC"
|
| 11185 |
},
|
| 11186 |
"node_modules/identity-obj-proxy": {
|
|
|
|
| 11316 |
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
| 11317 |
"license": "ISC"
|
| 11318 |
},
|
| 11319 |
+
"node_modules/int64-buffer": {
|
| 11320 |
+
"version": "0.1.10",
|
| 11321 |
+
"resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-0.1.10.tgz",
|
| 11322 |
+
"integrity": "sha512-v7cSY1J8ydZ0GyjUHqF+1bshJ6cnEVLo9EnjB8p+4HDRPZc9N5jjmvUV7NvEsqQOKyH0pmIBFWXVQbiS0+OBbA==",
|
| 11323 |
+
"license": "MIT"
|
| 11324 |
+
},
|
| 11325 |
"node_modules/internal-slot": {
|
| 11326 |
"version": "1.1.0",
|
| 11327 |
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
|
|
|
| 13458 |
"node": ">= 4.0.0"
|
| 13459 |
}
|
| 13460 |
},
|
| 13461 |
+
"node_modules/memoize-one": {
|
| 13462 |
+
"version": "5.2.1",
|
| 13463 |
+
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
| 13464 |
+
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
| 13465 |
+
"license": "MIT"
|
| 13466 |
+
},
|
| 13467 |
"node_modules/merge-descriptors": {
|
| 13468 |
"version": "1.0.3",
|
| 13469 |
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
|
|
|
| 13647 |
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 13648 |
"license": "MIT"
|
| 13649 |
},
|
| 13650 |
+
"node_modules/msgpack-lite": {
|
| 13651 |
+
"version": "0.1.26",
|
| 13652 |
+
"resolved": "https://registry.npmjs.org/msgpack-lite/-/msgpack-lite-0.1.26.tgz",
|
| 13653 |
+
"integrity": "sha512-SZ2IxeqZ1oRFGo0xFGbvBJWMp3yLIY9rlIJyxy8CGrwZn1f0ZK4r6jV/AM1r0FZMDUkWkglOk/eeKIL9g77Nxw==",
|
| 13654 |
+
"license": "MIT",
|
| 13655 |
+
"dependencies": {
|
| 13656 |
+
"event-lite": "^0.1.1",
|
| 13657 |
+
"ieee754": "^1.1.8",
|
| 13658 |
+
"int64-buffer": "^0.1.9",
|
| 13659 |
+
"isarray": "^1.0.0"
|
| 13660 |
+
},
|
| 13661 |
+
"bin": {
|
| 13662 |
+
"msgpack": "bin/msgpack"
|
| 13663 |
+
}
|
| 13664 |
+
},
|
| 13665 |
+
"node_modules/msgpack-lite/node_modules/isarray": {
|
| 13666 |
+
"version": "1.0.0",
|
| 13667 |
+
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
| 13668 |
+
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
| 13669 |
+
"license": "MIT"
|
| 13670 |
+
},
|
| 13671 |
"node_modules/multicast-dns": {
|
| 13672 |
"version": "7.2.5",
|
| 13673 |
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
|
|
|
| 16253 |
}
|
| 16254 |
}
|
| 16255 |
},
|
| 16256 |
+
"node_modules/react-virtualized-auto-sizer": {
|
| 16257 |
+
"version": "1.0.26",
|
| 16258 |
+
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz",
|
| 16259 |
+
"integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==",
|
| 16260 |
+
"license": "MIT",
|
| 16261 |
+
"peerDependencies": {
|
| 16262 |
+
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 16263 |
+
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 16264 |
+
}
|
| 16265 |
+
},
|
| 16266 |
+
"node_modules/react-window": {
|
| 16267 |
+
"version": "1.8.11",
|
| 16268 |
+
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
| 16269 |
+
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
|
| 16270 |
+
"license": "MIT",
|
| 16271 |
+
"dependencies": {
|
| 16272 |
+
"@babel/runtime": "^7.0.0",
|
| 16273 |
+
"memoize-one": ">=3.1.1 <6"
|
| 16274 |
+
},
|
| 16275 |
+
"engines": {
|
| 16276 |
+
"node": ">8.0.0"
|
| 16277 |
+
},
|
| 16278 |
+
"peerDependencies": {
|
| 16279 |
+
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 16280 |
+
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 16281 |
+
}
|
| 16282 |
+
},
|
| 16283 |
"node_modules/read-cache": {
|
| 16284 |
"version": "1.0.0",
|
| 16285 |
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
|
|
|
| 19494 |
"workbox-core": "6.6.0"
|
| 19495 |
}
|
| 19496 |
},
|
| 19497 |
+
"node_modules/workbox-background-sync/node_modules/idb": {
|
| 19498 |
+
"version": "7.1.1",
|
| 19499 |
+
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
| 19500 |
+
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
| 19501 |
+
"license": "ISC"
|
| 19502 |
+
},
|
| 19503 |
"node_modules/workbox-broadcast-update": {
|
| 19504 |
"version": "6.6.0",
|
| 19505 |
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.6.0.tgz",
|
|
|
|
| 19637 |
"workbox-core": "6.6.0"
|
| 19638 |
}
|
| 19639 |
},
|
| 19640 |
+
"node_modules/workbox-expiration/node_modules/idb": {
|
| 19641 |
+
"version": "7.1.1",
|
| 19642 |
+
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
| 19643 |
+
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
| 19644 |
+
"license": "ISC"
|
| 19645 |
+
},
|
| 19646 |
"node_modules/workbox-google-analytics": {
|
| 19647 |
"version": "6.6.0",
|
| 19648 |
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.6.0.tgz",
|
frontend/package.json
CHANGED
|
@@ -23,14 +23,24 @@
|
|
| 23 |
"@visx/visx": "^3.0.0",
|
| 24 |
"ajv": "^8.17.1",
|
| 25 |
"axios": "^1.6.0",
|
|
|
|
| 26 |
"d3": "^7.8.5",
|
|
|
|
|
|
|
| 27 |
"react": "^18.2.0",
|
| 28 |
"react-dom": "^18.2.0",
|
| 29 |
"react-scripts": "5.0.1",
|
|
|
|
|
|
|
| 30 |
"three": "^0.160.1",
|
| 31 |
"typescript": "^5.0.0",
|
| 32 |
"zustand": "^5.0.8"
|
| 33 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"scripts": {
|
| 35 |
"start": "react-scripts start",
|
| 36 |
"build": "react-scripts build",
|
|
|
|
| 23 |
"@visx/visx": "^3.0.0",
|
| 24 |
"ajv": "^8.17.1",
|
| 25 |
"axios": "^1.6.0",
|
| 26 |
+
"comlink": "^4.4.1",
|
| 27 |
"d3": "^7.8.5",
|
| 28 |
+
"idb": "^8.0.0",
|
| 29 |
+
"msgpack-lite": "^0.1.26",
|
| 30 |
"react": "^18.2.0",
|
| 31 |
"react-dom": "^18.2.0",
|
| 32 |
"react-scripts": "5.0.1",
|
| 33 |
+
"react-virtualized-auto-sizer": "^1.0.24",
|
| 34 |
+
"react-window": "^1.8.10",
|
| 35 |
"three": "^0.160.1",
|
| 36 |
"typescript": "^5.0.0",
|
| 37 |
"zustand": "^5.0.8"
|
| 38 |
},
|
| 39 |
+
"devDependencies": {
|
| 40 |
+
"@types/msgpack-lite": "^0.1.11",
|
| 41 |
+
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
| 42 |
+
"@types/react-window": "^1.8.8"
|
| 43 |
+
},
|
| 44 |
"scripts": {
|
| 45 |
"start": "react-scripts start",
|
| 46 |
"build": "react-scripts build",
|
frontend/public/index.html
CHANGED
|
@@ -8,9 +8,6 @@
|
|
| 8 |
name="description"
|
| 9 |
content="Anatomy of a Machine Learning Ecosystem: 2 Million Models on Hugging Face. Analysis of 1.86 million models on Hugging Face, revealing fine-tuning lineages and model family structures using evolutionary biology methods."
|
| 10 |
/>
|
| 11 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 12 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 13 |
-
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 14 |
<title>Anatomy of a Machine Learning Ecosystem: 2 Million Models on Hugging Face</title>
|
| 15 |
</head>
|
| 16 |
<body>
|
|
|
|
| 8 |
name="description"
|
| 9 |
content="Anatomy of a Machine Learning Ecosystem: 2 Million Models on Hugging Face. Analysis of 1.86 million models on Hugging Face, revealing fine-tuning lineages and model family structures using evolutionary biology methods."
|
| 10 |
/>
|
|
|
|
|
|
|
|
|
|
| 11 |
<title>Anatomy of a Machine Learning Ecosystem: 2 Million Models on Hugging Face</title>
|
| 12 |
</head>
|
| 13 |
<body>
|
frontend/src/App.css
CHANGED
|
@@ -1,764 +1,770 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
.
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
.
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
.
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
.
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
.
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
.sidebar
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
.
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
.sidebar
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
.
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
.
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
.
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
.
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
}
|
| 385 |
-
|
| 386 |
-
.
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
opacity: 0;
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
}
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
opacity: 1;
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
}
|
| 444 |
-
|
| 445 |
-
.
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
left: 50%;
|
| 450 |
-
transform: translateX(-50%);
|
| 451 |
-
padding: 0.5rem 0.75rem;
|
| 452 |
-
background: #1a1a1a;
|
| 453 |
-
color: white;
|
| 454 |
-
border-radius: 4px;
|
| 455 |
-
font-size: 0.75rem;
|
| 456 |
-
white-space: nowrap;
|
| 457 |
-
opacity: 0;
|
| 458 |
-
pointer-events: none;
|
| 459 |
-
transition: opacity 0.2s;
|
| 460 |
-
z-index: 1000;
|
| 461 |
-
margin-bottom: 0.5rem;
|
| 462 |
-
}
|
| 463 |
-
|
| 464 |
-
.tooltip:hover::after {
|
| 465 |
-
opacity: 1;
|
| 466 |
-
}
|
| 467 |
-
|
| 468 |
-
/* Theme Support */
|
| 469 |
-
[data-theme="dark"] {
|
| 470 |
-
--bg-primary: #1a1a1a;
|
| 471 |
-
--bg-secondary: #2d2d2d;
|
| 472 |
-
--bg-tertiary: #3a3a3a;
|
| 473 |
-
--text-primary: #ffffff;
|
| 474 |
-
--text-secondary: #cccccc;
|
| 475 |
-
--border-color: #444444;
|
| 476 |
-
--accent-color: #4a4a4a;
|
| 477 |
-
}
|
| 478 |
-
|
| 479 |
-
[data-theme="light"] {
|
| 480 |
-
--bg-primary: #ffffff;
|
| 481 |
-
--bg-secondary: #f5f5f5;
|
| 482 |
-
--bg-tertiary: #e8e8e8;
|
| 483 |
-
--text-primary: #1a1a1a;
|
| 484 |
-
--text-secondary: #666666;
|
| 485 |
-
--border-color: #d0d0d0;
|
| 486 |
-
--accent-color: #4a4a4a;
|
| 487 |
-
}
|
| 488 |
-
|
| 489 |
-
/* Random Model Button */
|
| 490 |
-
.random-model-btn {
|
| 491 |
-
display: flex;
|
| 492 |
-
align-items: center;
|
| 493 |
-
justify-content: center;
|
| 494 |
-
padding: 0.625rem 1.25rem;
|
| 495 |
-
background: #2d2d2d;
|
| 496 |
-
color: white;
|
| 497 |
-
border: none;
|
| 498 |
-
border-radius: 4px;
|
| 499 |
-
cursor: pointer;
|
| 500 |
-
font-size: 0.9rem;
|
| 501 |
-
font-family: 'Instrument Sans', sans-serif;
|
| 502 |
-
font-weight: 600;
|
| 503 |
-
transition: all 0.2s;
|
| 504 |
-
width: 100%;
|
| 505 |
-
}
|
| 506 |
-
|
| 507 |
-
.random-model-btn:hover:not(:disabled) {
|
| 508 |
-
background: #1a1a1a;
|
| 509 |
-
transform: translateY(-1px);
|
| 510 |
-
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
| 511 |
-
}
|
| 512 |
-
|
| 513 |
-
.random-model-btn:disabled {
|
| 514 |
-
opacity: 0.5;
|
| 515 |
-
cursor: not-allowed;
|
| 516 |
-
}
|
| 517 |
-
|
| 518 |
-
/* Zoom Slider */
|
| 519 |
-
.zoom-slider-container {
|
| 520 |
-
margin-bottom: 1rem;
|
| 521 |
-
}
|
| 522 |
-
|
| 523 |
-
.zoom-slider-label {
|
| 524 |
-
display: flex;
|
| 525 |
-
justify-content: space-between;
|
| 526 |
-
align-items: center;
|
| 527 |
-
margin-bottom: 0.5rem;
|
| 528 |
-
font-size: 0.9rem;
|
| 529 |
-
font-weight: 500;
|
| 530 |
-
color: var(--text-primary, #1a1a1a);
|
| 531 |
-
}
|
| 532 |
-
|
| 533 |
-
.zoom-value {
|
| 534 |
-
font-size: 0.85rem;
|
| 535 |
-
color: var(--text-secondary, #666);
|
| 536 |
-
font-family: 'Monaco', 'Menlo', monospace;
|
| 537 |
-
}
|
| 538 |
-
|
| 539 |
-
.zoom-slider {
|
| 540 |
-
width: 100%;
|
| 541 |
-
height: 6px;
|
| 542 |
-
border-radius: 3px;
|
| 543 |
-
background: var(--bg-tertiary, #e8e8e8);
|
| 544 |
-
outline: none;
|
| 545 |
-
-webkit-appearance: none;
|
| 546 |
-
}
|
| 547 |
-
|
| 548 |
-
.zoom-slider::-webkit-slider-thumb {
|
| 549 |
-
-webkit-appearance: none;
|
| 550 |
-
appearance: none;
|
| 551 |
-
width: 16px;
|
| 552 |
-
height: 16px;
|
| 553 |
-
border-radius: 50%;
|
| 554 |
-
background: var(--accent-color, #4a90e2);
|
| 555 |
-
cursor: pointer;
|
| 556 |
-
transition: all 0.2s;
|
| 557 |
-
}
|
| 558 |
-
|
| 559 |
-
.zoom-slider::-webkit-slider-thumb:hover {
|
| 560 |
-
transform: scale(1.2);
|
| 561 |
-
box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2);
|
| 562 |
-
}
|
| 563 |
-
|
| 564 |
-
.zoom-slider::-moz-range-thumb {
|
| 565 |
-
width: 16px;
|
| 566 |
-
height: 16px;
|
| 567 |
-
border-radius: 50%;
|
| 568 |
-
background: var(--accent-color, #4a90e2);
|
| 569 |
-
cursor: pointer;
|
| 570 |
-
border: none;
|
| 571 |
-
transition: all 0.2s;
|
| 572 |
-
}
|
| 573 |
-
|
| 574 |
-
.zoom-slider::-moz-range-thumb:hover {
|
| 575 |
-
transform: scale(1.2);
|
| 576 |
-
box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2);
|
| 577 |
-
}
|
| 578 |
-
|
| 579 |
-
.zoom-slider:disabled {
|
| 580 |
-
opacity: 0.5;
|
| 581 |
-
cursor: not-allowed;
|
| 582 |
-
}
|
| 583 |
-
|
| 584 |
-
/* Theme Toggle */
|
| 585 |
-
.theme-toggle {
|
| 586 |
-
background: var(--bg-secondary, #f5f5f5);
|
| 587 |
-
border: 1px solid var(--border-color, #d0d0d0);
|
| 588 |
-
border-radius: 4px;
|
| 589 |
-
padding: 0.5rem;
|
| 590 |
-
cursor: pointer;
|
| 591 |
-
font-size: 1.2rem;
|
| 592 |
-
transition: all 0.2s;
|
| 593 |
-
display: flex;
|
| 594 |
-
align-items: center;
|
| 595 |
-
justify-content: center;
|
| 596 |
-
min-width: 40px;
|
| 597 |
-
height: 40px;
|
| 598 |
-
}
|
| 599 |
-
|
| 600 |
-
.theme-toggle:hover {
|
| 601 |
-
background: var(--bg-tertiary, #e8e8e8);
|
| 602 |
-
transform: scale(1.05);
|
| 603 |
-
}
|
| 604 |
-
|
| 605 |
-
/* Label Toggle */
|
| 606 |
-
.label-toggle {
|
| 607 |
-
display: flex;
|
| 608 |
-
align-items: center;
|
| 609 |
-
justify-content: space-between;
|
| 610 |
-
margin-bottom: 1rem;
|
| 611 |
-
padding: 0.75rem;
|
| 612 |
-
background: var(--bg-secondary, #f5f5f5);
|
| 613 |
-
border-radius: 4px;
|
| 614 |
-
border: 1px solid var(--border-color, #e0e0e0);
|
| 615 |
-
}
|
| 616 |
-
|
| 617 |
-
.label-toggle-label {
|
| 618 |
-
font-size: 0.9rem;
|
| 619 |
-
font-weight: 500;
|
| 620 |
-
color: var(--text-primary, #1a1a1a);
|
| 621 |
-
}
|
| 622 |
-
|
| 623 |
-
.label-toggle-switch {
|
| 624 |
-
position: relative;
|
| 625 |
-
width: 44px;
|
| 626 |
-
height: 24px;
|
| 627 |
-
}
|
| 628 |
-
|
| 629 |
-
.label-toggle-switch input {
|
| 630 |
-
opacity: 0;
|
| 631 |
-
width: 0;
|
| 632 |
-
height: 0;
|
| 633 |
-
}
|
| 634 |
-
|
| 635 |
-
.label-toggle-slider {
|
| 636 |
-
position: absolute;
|
| 637 |
-
cursor: pointer;
|
| 638 |
-
top: 0;
|
| 639 |
-
left: 0;
|
| 640 |
-
right: 0;
|
| 641 |
-
bottom: 0;
|
| 642 |
-
background-color: #ccc;
|
| 643 |
-
transition: 0.3s;
|
| 644 |
-
border-radius: 24px;
|
| 645 |
-
}
|
| 646 |
-
|
| 647 |
-
.label-toggle-slider:before {
|
| 648 |
-
position: absolute;
|
| 649 |
-
content: "";
|
| 650 |
-
height: 18px;
|
| 651 |
-
width: 18px;
|
| 652 |
-
left: 3px;
|
| 653 |
-
bottom: 3px;
|
| 654 |
-
background-color: white;
|
| 655 |
-
transition: 0.3s;
|
| 656 |
-
border-radius: 50%;
|
| 657 |
-
}
|
| 658 |
-
|
| 659 |
-
.label-toggle-switch input:checked + .label-toggle-slider {
|
| 660 |
-
background-color: var(--accent-color, #4a90e2);
|
| 661 |
-
}
|
| 662 |
-
|
| 663 |
-
.label-toggle-switch input:checked + .label-toggle-slider:before {
|
| 664 |
-
transform: translateX(20px);
|
| 665 |
-
}
|
| 666 |
-
|
| 667 |
-
/* Cluster Filtering */
|
| 668 |
-
.cluster-filter-section {
|
| 669 |
-
margin-bottom: 1rem;
|
| 670 |
-
}
|
| 671 |
-
|
| 672 |
-
.cluster-search {
|
| 673 |
-
width: 100%;
|
| 674 |
-
padding: 0.5rem;
|
| 675 |
-
border: 1px solid var(--border-color, #d0d0d0);
|
| 676 |
-
border-radius: 4px;
|
| 677 |
-
font-size: 0.85rem;
|
| 678 |
-
margin-bottom: 0.5rem;
|
| 679 |
-
background: var(--bg-primary, #ffffff);
|
| 680 |
-
color: var(--text-primary, #1a1a1a);
|
| 681 |
-
}
|
| 682 |
-
|
| 683 |
-
.cluster-search:focus {
|
| 684 |
-
outline: none;
|
| 685 |
-
border-color: var(--accent-color, #4a90e2);
|
| 686 |
-
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1);
|
| 687 |
-
}
|
| 688 |
-
|
| 689 |
-
.cluster-buttons {
|
| 690 |
-
display: flex;
|
| 691 |
-
gap: 0.5rem;
|
| 692 |
-
margin-bottom: 0.75rem;
|
| 693 |
-
flex-wrap: wrap;
|
| 694 |
-
}
|
| 695 |
-
|
| 696 |
-
.cluster-button {
|
| 697 |
-
padding: 0.35rem 0.75rem;
|
| 698 |
-
background: var(--bg-secondary, #f5f5f5);
|
| 699 |
-
border: 1px solid var(--border-color, #d0d0d0);
|
| 700 |
-
border-radius: 4px;
|
| 701 |
-
cursor: pointer;
|
| 702 |
-
font-size: 0.8rem;
|
| 703 |
-
font-family: 'Instrument Sans', sans-serif;
|
| 704 |
-
transition: all 0.2s;
|
| 705 |
-
color: var(--text-primary, #1a1a1a);
|
| 706 |
-
}
|
| 707 |
-
|
| 708 |
-
.cluster-button:hover {
|
| 709 |
-
background: var(--bg-tertiary, #e8e8e8);
|
| 710 |
-
}
|
| 711 |
-
|
| 712 |
-
.cluster-button.active {
|
| 713 |
-
background: var(--accent-color, #4a90e2);
|
| 714 |
-
color: white;
|
| 715 |
-
border-color: var(--accent-color, #4a90e2);
|
| 716 |
-
}
|
| 717 |
-
|
| 718 |
-
.cluster-list {
|
| 719 |
-
max-height: 200px;
|
| 720 |
-
overflow-y: auto;
|
| 721 |
-
border: 1px solid var(--border-color, #e0e0e0);
|
| 722 |
-
border-radius: 4px;
|
| 723 |
-
padding: 0.5rem;
|
| 724 |
-
background: var(--bg-primary, #ffffff);
|
| 725 |
-
}
|
| 726 |
-
|
| 727 |
-
.cluster-item {
|
| 728 |
-
display: flex;
|
| 729 |
-
align-items: center;
|
| 730 |
-
padding: 0.5rem;
|
| 731 |
-
cursor: pointer;
|
| 732 |
-
border-radius: 3px;
|
| 733 |
-
transition: background 0.15s;
|
| 734 |
-
font-size: 0.85rem;
|
| 735 |
-
}
|
| 736 |
-
|
| 737 |
-
.cluster-item:hover {
|
| 738 |
-
background: var(--bg-secondary, #f5f5f5);
|
| 739 |
-
}
|
| 740 |
-
|
| 741 |
-
.sidebar input[type="checkbox"] {
|
| 742 |
-
width: 18px;
|
| 743 |
-
height: 18px;
|
| 744 |
-
cursor: pointer;
|
| 745 |
-
accent-color: #4a4a4a;
|
| 746 |
-
margin-right: 0.5rem;
|
| 747 |
-
}
|
| 748 |
-
|
| 749 |
-
.cluster-item input[type="checkbox"] {
|
| 750 |
-
margin-right: 0.5rem;
|
| 751 |
-
cursor: pointer;
|
| 752 |
-
}
|
| 753 |
-
|
| 754 |
-
.cluster-item-label {
|
| 755 |
-
flex: 1;
|
| 756 |
-
color: var(--text-primary, #1a1a1a);
|
| 757 |
-
}
|
| 758 |
-
|
| 759 |
-
.cluster-item-count {
|
| 760 |
-
font-size: 0.75rem;
|
| 761 |
-
color: var(--text-secondary, #666);
|
| 762 |
-
margin-left: auto;
|
| 763 |
-
}
|
| 764 |
-
|
|
|
|
| 1 |
+
/* ============================================
|
| 2 |
+
ROOT & VARIABLES
|
| 3 |
+
============================================ */
|
| 4 |
+
:root {
|
| 5 |
+
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 6 |
+
--font-mono: 'Monaco', 'Menlo', monospace;
|
| 7 |
+
|
| 8 |
+
/* Light theme (default) */
|
| 9 |
+
--bg-primary: #ffffff;
|
| 10 |
+
--bg-secondary: #fafafa;
|
| 11 |
+
--bg-tertiary: #f5f5f5;
|
| 12 |
+
--bg-elevated: #ffffff;
|
| 13 |
+
|
| 14 |
+
--text-primary: #1a1a1a;
|
| 15 |
+
--text-secondary: #666666;
|
| 16 |
+
--text-tertiary: #999999;
|
| 17 |
+
|
| 18 |
+
--border-light: #e8e8e8;
|
| 19 |
+
--border-medium: #d0d0d0;
|
| 20 |
+
--border-dark: #b0b0b0;
|
| 21 |
+
|
| 22 |
+
--accent-primary: #2d2d2d;
|
| 23 |
+
--accent-hover: #1a1a1a;
|
| 24 |
+
--accent-blue: #4a90e2;
|
| 25 |
+
--accent-blue-hover: #357abd;
|
| 26 |
+
|
| 27 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 28 |
+
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.08);
|
| 29 |
+
--shadow-lg: 0 2px 8px rgba(0, 0, 0, 0.12);
|
| 30 |
+
|
| 31 |
+
--radius-sm: 0;
|
| 32 |
+
--radius-md: 0;
|
| 33 |
+
--radius-lg: 0;
|
| 34 |
+
--radius-full: 0;
|
| 35 |
+
|
| 36 |
+
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 37 |
+
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 38 |
+
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
[data-theme="dark"] {
|
| 42 |
+
--bg-primary: #1a1a1a;
|
| 43 |
+
--bg-secondary: #2d2d2d;
|
| 44 |
+
--bg-tertiary: #3a3a3a;
|
| 45 |
+
--bg-elevated: #2d2d2d;
|
| 46 |
+
|
| 47 |
+
--text-primary: #ffffff;
|
| 48 |
+
--text-secondary: #cccccc;
|
| 49 |
+
--text-tertiary: #999999;
|
| 50 |
+
|
| 51 |
+
--border-light: #3a3a3a;
|
| 52 |
+
--border-medium: #444444;
|
| 53 |
+
--border-dark: #555555;
|
| 54 |
+
|
| 55 |
+
--accent-primary: #4a4a4a;
|
| 56 |
+
--accent-hover: #5a5a5a;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* ============================================
|
| 60 |
+
BASE STYLES
|
| 61 |
+
============================================ */
|
| 62 |
+
.App {
|
| 63 |
+
font-family: var(--font-primary);
|
| 64 |
+
-webkit-font-smoothing: antialiased;
|
| 65 |
+
-moz-osx-font-smoothing: grayscale;
|
| 66 |
+
color: var(--text-primary);
|
| 67 |
+
background: var(--bg-secondary);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* ============================================
|
| 71 |
+
HEADER
|
| 72 |
+
============================================ */
|
| 73 |
+
.App-header {
|
| 74 |
+
background: var(--accent-primary);
|
| 75 |
+
color: var(--text-primary);
|
| 76 |
+
padding: 1rem 2rem;
|
| 77 |
+
border-bottom: 1px solid var(--border-medium);
|
| 78 |
+
box-shadow: var(--shadow-md);
|
| 79 |
+
width: 100%;
|
| 80 |
+
box-sizing: border-box;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.App-header h1 {
|
| 84 |
+
margin: 0;
|
| 85 |
+
font-size: 1.5rem;
|
| 86 |
+
font-weight: 600;
|
| 87 |
+
letter-spacing: -0.01em;
|
| 88 |
+
line-height: 1.3;
|
| 89 |
+
color: #ffffff;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.App-header a {
|
| 93 |
+
color: #64b5f6;
|
| 94 |
+
text-decoration: none;
|
| 95 |
+
font-weight: 500;
|
| 96 |
+
transition: all var(--transition-base);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.App-header a:hover {
|
| 100 |
+
color: #90caf9;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.stats {
|
| 104 |
+
display: flex;
|
| 105 |
+
gap: 0.75rem;
|
| 106 |
+
font-size: 0.85rem;
|
| 107 |
+
flex-wrap: wrap;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.stats span {
|
| 111 |
+
padding: 0.5rem 0.875rem;
|
| 112 |
+
background: rgba(255, 255, 255, 0.1);
|
| 113 |
+
border-radius: 0;
|
| 114 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 115 |
+
font-weight: 500;
|
| 116 |
+
transition: all var(--transition-base);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.stats span:hover {
|
| 120 |
+
background: rgba(255, 255, 255, 0.15);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* ============================================
|
| 124 |
+
LAYOUT
|
| 125 |
+
============================================ */
|
| 126 |
+
.main-content {
|
| 127 |
+
display: flex;
|
| 128 |
+
height: calc(100vh - 80px);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.sidebar {
|
| 132 |
+
width: 340px;
|
| 133 |
+
padding: 1.5rem;
|
| 134 |
+
background: var(--bg-secondary);
|
| 135 |
+
overflow-y: auto;
|
| 136 |
+
border-right: 1px solid var(--border-light);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.visualization {
|
| 140 |
+
flex: 1;
|
| 141 |
+
padding: 1.5rem;
|
| 142 |
+
display: flex;
|
| 143 |
+
align-items: center;
|
| 144 |
+
justify-content: center;
|
| 145 |
+
background: var(--bg-primary);
|
| 146 |
+
overflow: auto;
|
| 147 |
+
position: relative;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* ============================================
|
| 151 |
+
SIDEBAR COMPONENTS
|
| 152 |
+
============================================ */
|
| 153 |
+
.sidebar h2 {
|
| 154 |
+
margin-top: 0;
|
| 155 |
+
font-size: 1.25rem;
|
| 156 |
+
font-weight: 600;
|
| 157 |
+
letter-spacing: -0.01em;
|
| 158 |
+
color: var(--text-primary);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.sidebar h3 {
|
| 162 |
+
font-size: 0.9rem;
|
| 163 |
+
font-weight: 600;
|
| 164 |
+
margin: 0 0 0.875rem 0;
|
| 165 |
+
letter-spacing: -0.01em;
|
| 166 |
+
color: var(--text-primary);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.sidebar-section {
|
| 170 |
+
background: var(--bg-elevated);
|
| 171 |
+
border-radius: 0;
|
| 172 |
+
padding: 1.25rem;
|
| 173 |
+
margin-bottom: 1rem;
|
| 174 |
+
border: 1px solid var(--border-light);
|
| 175 |
+
transition: all var(--transition-base);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.sidebar-section:hover {
|
| 179 |
+
border-color: var(--border-medium);
|
| 180 |
+
box-shadow: var(--shadow-sm);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* ============================================
|
| 184 |
+
FORM ELEMENTS
|
| 185 |
+
============================================ */
|
| 186 |
+
.sidebar label {
|
| 187 |
+
display: block;
|
| 188 |
+
margin-bottom: 1.25rem;
|
| 189 |
+
font-weight: 500;
|
| 190 |
+
color: var(--text-primary);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.sidebar input[type="text"],
|
| 194 |
+
.sidebar select {
|
| 195 |
+
width: 100%;
|
| 196 |
+
padding: 0.75rem 1rem;
|
| 197 |
+
margin-top: 0.5rem;
|
| 198 |
+
border: 2px solid var(--border-light);
|
| 199 |
+
border-radius: 0;
|
| 200 |
+
font-size: 0.9rem;
|
| 201 |
+
font-family: var(--font-primary);
|
| 202 |
+
background: var(--bg-primary);
|
| 203 |
+
color: var(--text-primary);
|
| 204 |
+
transition: all var(--transition-base);
|
| 205 |
+
box-shadow: var(--shadow-sm);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.sidebar input[type="text"]:hover,
|
| 209 |
+
.sidebar select:hover {
|
| 210 |
+
border-color: var(--border-medium);
|
| 211 |
+
box-shadow: var(--shadow-md);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.sidebar input[type="text"]:focus,
|
| 215 |
+
.sidebar select:focus {
|
| 216 |
+
outline: none;
|
| 217 |
+
border-color: var(--accent-primary);
|
| 218 |
+
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/* Range Inputs */
|
| 222 |
+
.sidebar input[type="range"] {
|
| 223 |
+
width: 100%;
|
| 224 |
+
margin-top: 0.75rem;
|
| 225 |
+
height: 6px;
|
| 226 |
+
-webkit-appearance: none;
|
| 227 |
+
appearance: none;
|
| 228 |
+
background: var(--bg-tertiary);
|
| 229 |
+
border-radius: 0;
|
| 230 |
+
outline: none;
|
| 231 |
+
cursor: pointer;
|
| 232 |
+
transition: background var(--transition-base);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.sidebar input[type="range"]:hover {
|
| 236 |
+
background: var(--border-medium);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.sidebar input[type="range"]::-webkit-slider-thumb {
|
| 240 |
+
-webkit-appearance: none;
|
| 241 |
+
width: 18px;
|
| 242 |
+
height: 18px;
|
| 243 |
+
border-radius: 0;
|
| 244 |
+
background: var(--accent-primary);
|
| 245 |
+
cursor: pointer;
|
| 246 |
+
box-shadow: var(--shadow-md);
|
| 247 |
+
border: 2px solid var(--bg-primary);
|
| 248 |
+
transition: all var(--transition-base);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.sidebar input[type="range"]::-webkit-slider-thumb:hover {
|
| 252 |
+
background: var(--accent-hover);
|
| 253 |
+
transform: scale(1.15);
|
| 254 |
+
box-shadow: var(--shadow-lg);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.sidebar input[type="range"]::-moz-range-thumb {
|
| 258 |
+
width: 18px;
|
| 259 |
+
height: 18px;
|
| 260 |
+
border-radius: 0;
|
| 261 |
+
background: var(--accent-primary);
|
| 262 |
+
cursor: pointer;
|
| 263 |
+
border: 2px solid var(--bg-primary);
|
| 264 |
+
box-shadow: var(--shadow-md);
|
| 265 |
+
transition: all var(--transition-base);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.sidebar input[type="range"]::-moz-range-thumb:hover {
|
| 269 |
+
background: var(--accent-hover);
|
| 270 |
+
transform: scale(1.15);
|
| 271 |
+
box-shadow: var(--shadow-lg);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
/* Checkbox */
|
| 275 |
+
.sidebar input[type="checkbox"] {
|
| 276 |
+
width: 18px;
|
| 277 |
+
height: 18px;
|
| 278 |
+
cursor: pointer;
|
| 279 |
+
accent-color: var(--accent-primary);
|
| 280 |
+
margin-right: 0.5rem;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/* ============================================
|
| 284 |
+
BUTTONS
|
| 285 |
+
============================================ */
|
| 286 |
+
.btn {
|
| 287 |
+
padding: 0.625rem 1.25rem;
|
| 288 |
+
border-radius: 0;
|
| 289 |
+
border: none;
|
| 290 |
+
font-size: 0.9rem;
|
| 291 |
+
font-weight: 600;
|
| 292 |
+
cursor: pointer;
|
| 293 |
+
font-family: var(--font-primary);
|
| 294 |
+
display: inline-flex;
|
| 295 |
+
align-items: center;
|
| 296 |
+
justify-content: center;
|
| 297 |
+
gap: 0.5rem;
|
| 298 |
+
transition: all var(--transition-base);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.btn-primary {
|
| 302 |
+
background: var(--accent-primary);
|
| 303 |
+
color: white;
|
| 304 |
+
box-shadow: var(--shadow-md);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.btn-primary:hover:not(:disabled) {
|
| 308 |
+
background: var(--accent-hover);
|
| 309 |
+
transform: translateY(-1px);
|
| 310 |
+
box-shadow: var(--shadow-lg);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.btn-secondary {
|
| 314 |
+
background: var(--bg-tertiary);
|
| 315 |
+
color: var(--text-primary);
|
| 316 |
+
border: 1px solid var(--border-medium);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.btn-secondary:hover:not(:disabled) {
|
| 320 |
+
background: var(--bg-secondary);
|
| 321 |
+
border-color: var(--border-dark);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.btn-small {
|
| 325 |
+
padding: 0.4rem 0.875rem;
|
| 326 |
+
font-size: 0.85rem;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.btn:disabled {
|
| 330 |
+
opacity: 0.5;
|
| 331 |
+
cursor: not-allowed;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.random-model-btn {
|
| 335 |
+
width: 100%;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/* ============================================
|
| 339 |
+
CHIPS & FILTERS
|
| 340 |
+
============================================ */
|
| 341 |
+
.filter-chip {
|
| 342 |
+
display: inline-flex;
|
| 343 |
+
align-items: center;
|
| 344 |
+
gap: 0.5rem;
|
| 345 |
+
padding: 0.4rem 0.75rem;
|
| 346 |
+
background: #e3f2fd;
|
| 347 |
+
color: #1976d2;
|
| 348 |
+
border-radius: 0;
|
| 349 |
+
font-size: 0.8rem;
|
| 350 |
+
font-weight: 500;
|
| 351 |
+
border: 1px solid #90caf9;
|
| 352 |
+
cursor: pointer;
|
| 353 |
+
transition: all var(--transition-base);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.filter-chip:hover {
|
| 357 |
+
background: #bbdefb;
|
| 358 |
+
transform: translateY(-1px);
|
| 359 |
+
box-shadow: var(--shadow-sm);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.filter-chip.active {
|
| 363 |
+
background: #1976d2;
|
| 364 |
+
color: white;
|
| 365 |
+
border-color: #1976d2;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.filter-chip .remove {
|
| 369 |
+
cursor: pointer;
|
| 370 |
+
font-weight: bold;
|
| 371 |
+
opacity: 0.7;
|
| 372 |
+
transition: opacity var(--transition-fast);
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.filter-chip .remove:hover {
|
| 376 |
+
opacity: 1;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/* ============================================
|
| 380 |
+
CLUSTERS
|
| 381 |
+
============================================ */
|
| 382 |
+
.cluster-filter-section {
|
| 383 |
+
margin-bottom: 1rem;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.cluster-search {
|
| 387 |
+
width: 100%;
|
| 388 |
+
padding: 0.5rem;
|
| 389 |
+
border: 1px solid var(--border-medium);
|
| 390 |
+
border-radius: 0;
|
| 391 |
+
font-size: 0.85rem;
|
| 392 |
+
margin-bottom: 0.5rem;
|
| 393 |
+
background: var(--bg-primary);
|
| 394 |
+
color: var(--text-primary);
|
| 395 |
+
transition: all var(--transition-base);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.cluster-search:focus {
|
| 399 |
+
outline: none;
|
| 400 |
+
border-color: var(--accent-blue);
|
| 401 |
+
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.cluster-buttons {
|
| 405 |
+
display: flex;
|
| 406 |
+
gap: 0.5rem;
|
| 407 |
+
margin-bottom: 0.75rem;
|
| 408 |
+
flex-wrap: wrap;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.cluster-button {
|
| 412 |
+
padding: 0.35rem 0.75rem;
|
| 413 |
+
background: var(--bg-secondary);
|
| 414 |
+
border: 1px solid var(--border-medium);
|
| 415 |
+
border-radius: 0;
|
| 416 |
+
cursor: pointer;
|
| 417 |
+
font-size: 0.8rem;
|
| 418 |
+
font-family: var(--font-primary);
|
| 419 |
+
color: var(--text-primary);
|
| 420 |
+
transition: all var(--transition-base);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.cluster-button:hover {
|
| 424 |
+
background: var(--bg-tertiary);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.cluster-button.active {
|
| 428 |
+
background: var(--accent-blue);
|
| 429 |
+
color: white;
|
| 430 |
+
border-color: var(--accent-blue);
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.cluster-list {
|
| 434 |
+
max-height: 200px;
|
| 435 |
+
overflow-y: auto;
|
| 436 |
+
border: 1px solid var(--border-light);
|
| 437 |
+
border-radius: 0;
|
| 438 |
+
padding: 0.5rem;
|
| 439 |
+
background: var(--bg-primary);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.cluster-item {
|
| 443 |
+
display: flex;
|
| 444 |
+
align-items: center;
|
| 445 |
+
padding: 0.5rem;
|
| 446 |
+
cursor: pointer;
|
| 447 |
+
border-radius: 0;
|
| 448 |
+
font-size: 0.85rem;
|
| 449 |
+
transition: background var(--transition-fast);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.cluster-item:hover {
|
| 453 |
+
background: var(--bg-secondary);
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.cluster-item-label {
|
| 457 |
+
flex: 1;
|
| 458 |
+
color: var(--text-primary);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.cluster-item-count {
|
| 462 |
+
font-size: 0.75rem;
|
| 463 |
+
color: var(--text-secondary);
|
| 464 |
+
margin-left: auto;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
/* ============================================
|
| 468 |
+
COLLAPSIBLE SECTIONS
|
| 469 |
+
============================================ */
|
| 470 |
+
.collapsible-section {
|
| 471 |
+
margin-bottom: 1rem;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.collapsible-header {
|
| 475 |
+
display: flex;
|
| 476 |
+
justify-content: space-between;
|
| 477 |
+
align-items: center;
|
| 478 |
+
cursor: pointer;
|
| 479 |
+
padding: 0.75rem;
|
| 480 |
+
background: var(--bg-tertiary);
|
| 481 |
+
border-radius: 0;
|
| 482 |
+
user-select: none;
|
| 483 |
+
transition: background var(--transition-base);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.collapsible-header:hover {
|
| 487 |
+
background: var(--border-light);
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.collapsible-header h3 {
|
| 491 |
+
margin: 0;
|
| 492 |
+
font-size: 0.9rem;
|
| 493 |
+
font-weight: 600;
|
| 494 |
+
color: var(--text-primary);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.collapsible-content {
|
| 498 |
+
padding: 1rem 0.75rem;
|
| 499 |
+
animation: slideDown var(--transition-base) ease-out;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
@keyframes slideDown {
|
| 503 |
+
from {
|
| 504 |
+
opacity: 0;
|
| 505 |
+
transform: translateY(-8px);
|
| 506 |
+
}
|
| 507 |
+
to {
|
| 508 |
+
opacity: 1;
|
| 509 |
+
transform: translateY(0);
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
/* ============================================
|
| 514 |
+
TOGGLES & SWITCHES
|
| 515 |
+
============================================ */
|
| 516 |
+
.label-toggle {
|
| 517 |
+
display: flex;
|
| 518 |
+
align-items: center;
|
| 519 |
+
justify-content: space-between;
|
| 520 |
+
margin-bottom: 1rem;
|
| 521 |
+
padding: 0.75rem;
|
| 522 |
+
background: var(--bg-secondary);
|
| 523 |
+
border-radius: 0;
|
| 524 |
+
border: 1px solid var(--border-light);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.label-toggle-label {
|
| 528 |
+
font-size: 0.9rem;
|
| 529 |
+
font-weight: 500;
|
| 530 |
+
color: var(--text-primary);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.label-toggle-switch {
|
| 534 |
+
position: relative;
|
| 535 |
+
width: 44px;
|
| 536 |
+
height: 24px;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.label-toggle-switch input {
|
| 540 |
opacity: 0;
|
| 541 |
+
width: 0;
|
| 542 |
+
height: 0;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.label-toggle-slider {
|
| 546 |
+
position: absolute;
|
| 547 |
+
cursor: pointer;
|
| 548 |
+
inset: 0;
|
| 549 |
+
background-color: #ccc;
|
| 550 |
+
border-radius: 0;
|
| 551 |
+
transition: background-color var(--transition-slow);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.label-toggle-slider:before {
|
| 555 |
+
position: absolute;
|
| 556 |
+
content: "";
|
| 557 |
+
height: 18px;
|
| 558 |
+
width: 18px;
|
| 559 |
+
left: 3px;
|
| 560 |
+
bottom: 3px;
|
| 561 |
+
background-color: white;
|
| 562 |
+
border-radius: 0;
|
| 563 |
+
transition: transform var(--transition-slow);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.label-toggle-switch input:checked + .label-toggle-slider {
|
| 567 |
+
background-color: var(--accent-blue);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.label-toggle-switch input:checked + .label-toggle-slider:before {
|
| 571 |
+
transform: translateX(20px);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.theme-toggle {
|
| 575 |
+
background: var(--bg-secondary);
|
| 576 |
+
border: 1px solid var(--border-medium);
|
| 577 |
+
border-radius: 0;
|
| 578 |
+
padding: 0.5rem;
|
| 579 |
+
cursor: pointer;
|
| 580 |
+
font-size: 1.2rem;
|
| 581 |
+
display: flex;
|
| 582 |
+
align-items: center;
|
| 583 |
+
justify-content: center;
|
| 584 |
+
min-width: 40px;
|
| 585 |
+
height: 40px;
|
| 586 |
+
transition: all var(--transition-base);
|
| 587 |
}
|
| 588 |
+
|
| 589 |
+
.theme-toggle:hover {
|
| 590 |
+
background: var(--bg-tertiary);
|
| 591 |
+
transform: scale(1.05);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
/* ============================================
|
| 595 |
+
ZOOM CONTROLS
|
| 596 |
+
============================================ */
|
| 597 |
+
.zoom-slider-container {
|
| 598 |
+
margin-bottom: 1rem;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.zoom-slider-label {
|
| 602 |
+
display: flex;
|
| 603 |
+
justify-content: space-between;
|
| 604 |
+
align-items: center;
|
| 605 |
+
margin-bottom: 0.5rem;
|
| 606 |
+
font-size: 0.9rem;
|
| 607 |
+
font-weight: 500;
|
| 608 |
+
color: var(--text-primary);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.zoom-value {
|
| 612 |
+
font-size: 0.85rem;
|
| 613 |
+
color: var(--text-secondary);
|
| 614 |
+
font-family: var(--font-mono);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.zoom-slider {
|
| 618 |
+
width: 100%;
|
| 619 |
+
height: 6px;
|
| 620 |
+
border-radius: 0;
|
| 621 |
+
background: var(--bg-tertiary);
|
| 622 |
+
outline: none;
|
| 623 |
+
-webkit-appearance: none;
|
| 624 |
+
cursor: pointer;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.zoom-slider::-webkit-slider-thumb {
|
| 628 |
+
-webkit-appearance: none;
|
| 629 |
+
width: 16px;
|
| 630 |
+
height: 16px;
|
| 631 |
+
border-radius: 0;
|
| 632 |
+
background: var(--accent-blue);
|
| 633 |
+
cursor: pointer;
|
| 634 |
+
transition: all var(--transition-base);
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.zoom-slider::-webkit-slider-thumb:hover {
|
| 638 |
+
transform: scale(1.2);
|
| 639 |
+
box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2);
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.zoom-slider::-moz-range-thumb {
|
| 643 |
+
width: 16px;
|
| 644 |
+
height: 16px;
|
| 645 |
+
border-radius: 0;
|
| 646 |
+
background: var(--accent-blue);
|
| 647 |
+
cursor: pointer;
|
| 648 |
+
border: none;
|
| 649 |
+
transition: all var(--transition-base);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.zoom-slider::-moz-range-thumb:hover {
|
| 653 |
+
transform: scale(1.2);
|
| 654 |
+
box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.zoom-slider:disabled {
|
| 658 |
+
opacity: 0.5;
|
| 659 |
+
cursor: not-allowed;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
/* ============================================
|
| 663 |
+
STATE MESSAGES
|
| 664 |
+
============================================ */
|
| 665 |
+
.loading,
|
| 666 |
+
.error,
|
| 667 |
+
.empty {
|
| 668 |
+
text-align: center;
|
| 669 |
+
padding: 4rem 2.5rem;
|
| 670 |
+
font-size: 1.1rem;
|
| 671 |
+
display: flex;
|
| 672 |
+
flex-direction: column;
|
| 673 |
+
align-items: center;
|
| 674 |
+
gap: 1.5rem;
|
| 675 |
+
border-radius: 0;
|
| 676 |
+
max-width: 550px;
|
| 677 |
+
margin: 0 auto;
|
| 678 |
+
animation: fadeIn 0.4s ease-in-out;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
@keyframes fadeIn {
|
| 682 |
+
from {
|
| 683 |
+
opacity: 0;
|
| 684 |
+
transform: translateY(10px);
|
| 685 |
+
}
|
| 686 |
+
to {
|
| 687 |
+
opacity: 1;
|
| 688 |
+
transform: translateY(0);
|
| 689 |
+
}
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.loading {
|
| 693 |
+
color: var(--text-primary);
|
| 694 |
+
font-weight: 600;
|
| 695 |
+
background: var(--bg-tertiary);
|
| 696 |
+
border: 1px solid var(--border-medium);
|
| 697 |
+
box-shadow: var(--shadow-md);
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
.loading::after {
|
| 701 |
+
content: '';
|
| 702 |
+
width: 40px;
|
| 703 |
+
height: 40px;
|
| 704 |
+
border: 4px solid var(--border-light);
|
| 705 |
+
border-top-color: var(--accent-primary);
|
| 706 |
+
border-radius: 0;
|
| 707 |
+
animation: spin 0.8s linear infinite;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
@keyframes spin {
|
| 711 |
+
to {
|
| 712 |
+
transform: rotate(360deg);
|
| 713 |
+
}
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.error {
|
| 717 |
+
color: #d32f2f;
|
| 718 |
+
background: #ffebee;
|
| 719 |
+
border: 1px solid #ffcdd2;
|
| 720 |
+
font-weight: 500;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.empty {
|
| 724 |
+
color: var(--text-secondary);
|
| 725 |
+
background: var(--bg-tertiary);
|
| 726 |
+
border: 1px solid var(--border-light);
|
| 727 |
+
font-weight: 500;
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
/* ============================================
|
| 731 |
+
TOOLTIP
|
| 732 |
+
============================================ */
|
| 733 |
+
.tooltip {
|
| 734 |
+
position: relative;
|
| 735 |
+
cursor: help;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
.tooltip::after {
|
| 739 |
+
content: attr(data-tooltip);
|
| 740 |
+
position: absolute;
|
| 741 |
+
bottom: calc(100% + 0.5rem);
|
| 742 |
+
left: 50%;
|
| 743 |
+
transform: translateX(-50%);
|
| 744 |
+
padding: 0.5rem 0.75rem;
|
| 745 |
+
background: var(--text-primary);
|
| 746 |
+
color: var(--bg-primary);
|
| 747 |
+
border-radius: 0;
|
| 748 |
+
font-size: 0.75rem;
|
| 749 |
+
white-space: nowrap;
|
| 750 |
+
opacity: 0;
|
| 751 |
+
pointer-events: none;
|
| 752 |
+
transition: opacity var(--transition-base);
|
| 753 |
+
z-index: 1000;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.tooltip:hover::after {
|
| 757 |
opacity: 1;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
/* ============================================
|
| 761 |
+
VISUALIZATION
|
| 762 |
+
============================================ */
|
| 763 |
+
.visualization > * {
|
| 764 |
+
transition: opacity var(--transition-slow), transform var(--transition-slow);
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
.visualization svg {
|
| 768 |
+
display: block;
|
| 769 |
+
background: var(--bg-primary);
|
| 770 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/App.tsx
CHANGED
|
@@ -1,30 +1,31 @@
|
|
| 1 |
-
import React, { useState, useEffect, useCallback, useRef, useMemo
|
| 2 |
// Visualizations
|
| 3 |
-
import
|
|
|
|
| 4 |
import NetworkGraph from './components/visualizations/NetworkGraph';
|
| 5 |
-
import UVProjectionSquare from './components/visualizations/UVProjectionSquare';
|
| 6 |
import DistributionView from './components/visualizations/DistributionView';
|
| 7 |
-
import StackedView from './components/visualizations/StackedView';
|
| 8 |
-
import HeatmapView from './components/visualizations/HeatmapView';
|
| 9 |
// Controls
|
| 10 |
import RandomModelButton from './components/controls/RandomModelButton';
|
| 11 |
import ZoomSlider from './components/controls/ZoomSlider';
|
| 12 |
import ThemeToggle from './components/controls/ThemeToggle';
|
| 13 |
-
import RenderingStyleSelector from './components/controls/RenderingStyleSelector';
|
| 14 |
-
import VisualizationModeButtons from './components/controls/VisualizationModeButtons';
|
| 15 |
-
import ClusterFilter
|
|
|
|
| 16 |
import NodeDensitySlider from './components/controls/NodeDensitySlider';
|
| 17 |
// Modals
|
| 18 |
import ModelModal from './components/modals/ModelModal';
|
| 19 |
// UI Components
|
| 20 |
import LiveModelCount from './components/ui/LiveModelCount';
|
| 21 |
-
import ModelTooltip from './components/ui/ModelTooltip';
|
| 22 |
import ErrorBoundary from './components/ui/ErrorBoundary';
|
|
|
|
| 23 |
// Types & Utils
|
| 24 |
import { ModelPoint, Stats, FamilyTree, SearchResult, SimilarModel } from './types';
|
| 25 |
import cache, { IndexedDBCache } from './utils/data/indexedDB';
|
| 26 |
import { debounce } from './utils/debounce';
|
| 27 |
import requestManager from './utils/api/requestManager';
|
|
|
|
| 28 |
import { useFilterStore, ViewMode, ColorByOption, SizeByOption } from './stores/filterStore';
|
| 29 |
import { API_BASE } from './config/api';
|
| 30 |
import './App.css';
|
|
@@ -37,8 +38,6 @@ const logger = {
|
|
| 37 |
},
|
| 38 |
};
|
| 39 |
|
| 40 |
-
const ScatterPlot3D = lazy(() => import('./components/visualizations/ScatterPlot3D'));
|
| 41 |
-
|
| 42 |
function App() {
|
| 43 |
// Filter store state
|
| 44 |
const {
|
|
@@ -48,8 +47,8 @@ function App() {
|
|
| 48 |
colorScheme,
|
| 49 |
showLabels,
|
| 50 |
zoomLevel,
|
| 51 |
-
nodeDensity,
|
| 52 |
-
renderingStyle,
|
| 53 |
theme,
|
| 54 |
selectedClusters,
|
| 55 |
searchQuery,
|
|
@@ -61,9 +60,9 @@ function App() {
|
|
| 61 |
setColorScheme,
|
| 62 |
setShowLabels,
|
| 63 |
setZoomLevel,
|
| 64 |
-
setNodeDensity,
|
| 65 |
-
setRenderingStyle,
|
| 66 |
-
setSelectedClusters,
|
| 67 |
setSearchQuery,
|
| 68 |
setMinDownloads,
|
| 69 |
setMinLikes,
|
|
@@ -93,7 +92,7 @@ function App() {
|
|
| 93 |
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
| 94 |
const [searchInput, setSearchInput] = useState('');
|
| 95 |
const [showSearchResults, setShowSearchResults] = useState(false);
|
| 96 |
-
const [viewCenter, setViewCenter] = useState<{ x: number; y: number; z: number } | null>(null);
|
| 97 |
const [projectionMethod, setProjectionMethod] = useState<'umap' | 'tsne'>('umap');
|
| 98 |
const [bookmarkedModels, setBookmarkedModels] = useState<string[]>([]);
|
| 99 |
const [comparisonModels, setComparisonModels] = useState<ModelPoint[]>([]);
|
|
@@ -178,11 +177,19 @@ function App() {
|
|
| 178 |
projection_method: projectionMethod,
|
| 179 |
});
|
| 180 |
const url = `${API_BASE}/api/models/semantic-similarity?${params}`;
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
} else {
|
| 187 |
const params = new URLSearchParams({
|
| 188 |
min_downloads: minDownloads.toString(),
|
|
@@ -197,21 +204,57 @@ function App() {
|
|
| 197 |
params.append('search_query', searchQuery);
|
| 198 |
}
|
| 199 |
|
| 200 |
-
params.append('max_points', '
|
|
|
|
|
|
|
| 201 |
|
| 202 |
const url = `${API_BASE}/api/models?${params}`;
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
}
|
| 217 |
|
|
@@ -233,7 +276,7 @@ function App() {
|
|
| 233 |
setLoading(false);
|
| 234 |
fetchDataAbortRef.current = null;
|
| 235 |
}
|
| 236 |
-
}, [minDownloads, minLikes, searchQuery, colorBy, sizeBy, projectionMethod, baseModelsOnly, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters]);
|
| 237 |
|
| 238 |
const debouncedFetchData = useMemo(
|
| 239 |
() => debounce(fetchData, 300),
|
|
@@ -258,15 +301,43 @@ function App() {
|
|
| 258 |
};
|
| 259 |
}, [minDownloads, minLikes, colorBy, sizeBy, baseModelsOnly, projectionMethod, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, debouncedFetchData]);
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
useEffect(() => {
|
| 262 |
const fetchStats = async () => {
|
| 263 |
const cacheKey = 'stats';
|
| 264 |
const cachedStats = await cache.getCachedStats(cacheKey);
|
|
|
|
|
|
|
|
|
|
| 265 |
if (cachedStats) {
|
|
|
|
| 266 |
setStats(cachedStats);
|
| 267 |
-
return;
|
| 268 |
}
|
| 269 |
-
|
|
|
|
| 270 |
try {
|
| 271 |
const response = await fetch(`${API_BASE}/api/stats`);
|
| 272 |
if (!response.ok) throw new Error('Failed to fetch stats');
|
|
@@ -451,50 +522,54 @@ function App() {
|
|
| 451 |
<ErrorBoundary>
|
| 452 |
<div className="App">
|
| 453 |
<header className="App-header">
|
| 454 |
-
<
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
style={{ color: 'white', textDecoration: 'underline', fontWeight: '500' }}
|
| 463 |
-
>
|
| 464 |
-
Read the full paper
|
| 465 |
-
</a>
|
| 466 |
-
</p>
|
| 467 |
-
<p style={{ marginTop: '1rem', fontSize: '0.9rem', opacity: 0.9, lineHeight: '1.6' }}>
|
| 468 |
-
<strong>Resources:</strong>{' '}
|
| 469 |
-
<a
|
| 470 |
-
href="https://github.com/bendlaufer/ai-ecosystem"
|
| 471 |
-
target="_blank"
|
| 472 |
-
rel="noopener noreferrer"
|
| 473 |
-
style={{ color: '#64b5f6', textDecoration: 'underline', marginRight: '1rem' }}
|
| 474 |
-
>
|
| 475 |
-
GitHub Repository
|
| 476 |
-
</a>
|
| 477 |
-
<a
|
| 478 |
-
href="https://huggingface.co/modelbiome"
|
| 479 |
-
target="_blank"
|
| 480 |
-
rel="noopener noreferrer"
|
| 481 |
-
style={{ color: '#64b5f6', textDecoration: 'underline' }}
|
| 482 |
-
>
|
| 483 |
-
Hugging Face Dataset
|
| 484 |
-
</a>
|
| 485 |
-
</p>
|
| 486 |
-
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem', opacity: 0.9 }}>
|
| 487 |
-
<strong>Authors:</strong> Benjamin Laufer, Hamidah Oderinwale, Jon Kleinberg
|
| 488 |
-
</p>
|
| 489 |
-
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'center', width: '100%' }}>
|
| 490 |
-
<LiveModelCount compact={true} />
|
| 491 |
-
{stats && (
|
| 492 |
-
<div className="stats">
|
| 493 |
-
<span>Dataset Models: {stats.total_models.toLocaleString()}</span>
|
| 494 |
-
<span>Libraries: {stats.unique_libraries}</span>
|
| 495 |
-
<span>Task Types: {stats.unique_task_types ?? stats.unique_pipelines}</span>
|
| 496 |
</div>
|
| 497 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
</div>
|
| 499 |
</header>
|
| 500 |
|
|
@@ -522,7 +597,7 @@ function App() {
|
|
| 522 |
background: '#4a4a4a',
|
| 523 |
color: 'white',
|
| 524 |
padding: '0.35rem 0.7rem',
|
| 525 |
-
borderRadius: '
|
| 526 |
fontWeight: '600'
|
| 527 |
}}>
|
| 528 |
{activeFilterCount} active
|
|
@@ -553,7 +628,7 @@ function App() {
|
|
| 553 |
background: '#4a4a4a',
|
| 554 |
color: 'white',
|
| 555 |
padding: '0.3rem 0.6rem',
|
| 556 |
-
borderRadius: '
|
| 557 |
fontWeight: '600'
|
| 558 |
}}>
|
| 559 |
Graph
|
|
@@ -575,17 +650,15 @@ function App() {
|
|
| 575 |
|
| 576 |
{/* Search Section */}
|
| 577 |
<div className="sidebar-section">
|
| 578 |
-
<h3>Search
|
| 579 |
<input
|
| 580 |
type="text"
|
| 581 |
value={searchQuery}
|
| 582 |
onChange={(e) => setSearchQuery(e.target.value)}
|
| 583 |
-
placeholder="Search
|
| 584 |
style={{ width: '100%' }}
|
|
|
|
| 585 |
/>
|
| 586 |
-
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.5rem', lineHeight: '1.4' }}>
|
| 587 |
-
Search by model name, tags, library, or metadata
|
| 588 |
-
</div>
|
| 589 |
</div>
|
| 590 |
|
| 591 |
{/* Popularity Filters */}
|
|
@@ -639,24 +712,26 @@ function App() {
|
|
| 639 |
</label>
|
| 640 |
</div>
|
| 641 |
|
| 642 |
-
{/* License Filter */}
|
| 643 |
{stats && stats.licenses && typeof stats.licenses === 'object' && Object.keys(stats.licenses).length > 0 && (
|
| 644 |
-
<
|
| 645 |
-
<
|
| 646 |
-
|
|
|
|
|
|
|
| 647 |
{Object.entries(stats.licenses as Record<string, number>)
|
| 648 |
-
.sort((a, b) => b[1] - a[1])
|
| 649 |
-
.slice(0, 20)
|
| 650 |
.map(([license, count]) => (
|
| 651 |
<label
|
| 652 |
key={license}
|
| 653 |
style={{
|
| 654 |
display: 'flex',
|
| 655 |
alignItems: 'center',
|
| 656 |
-
gap: '0.
|
| 657 |
-
marginBottom: '0.
|
| 658 |
cursor: 'pointer',
|
| 659 |
-
fontSize: '0.
|
| 660 |
}}
|
| 661 |
>
|
| 662 |
<input
|
|
@@ -664,80 +739,96 @@ function App() {
|
|
| 664 |
checked={searchQuery.toLowerCase().includes(license.toLowerCase())}
|
| 665 |
onChange={(e) => {
|
| 666 |
if (e.target.checked) {
|
| 667 |
-
// Add license to search (simple implementation)
|
| 668 |
setSearchQuery(searchQuery ? `${searchQuery} ${license}` : license);
|
| 669 |
} else {
|
| 670 |
-
// Remove license from search
|
| 671 |
setSearchQuery(searchQuery.replace(license, '').trim() || '');
|
| 672 |
}
|
| 673 |
}}
|
| 674 |
/>
|
| 675 |
<span style={{ flex: 1 }}>{license || 'Unknown'}</span>
|
| 676 |
-
<span style={{ fontSize: '0.
|
| 677 |
</label>
|
| 678 |
))}
|
| 679 |
</div>
|
| 680 |
-
|
| 681 |
-
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.5rem' }}>
|
| 682 |
-
Showing top 20 licenses
|
| 683 |
-
</div>
|
| 684 |
-
)}
|
| 685 |
-
</div>
|
| 686 |
)}
|
| 687 |
|
| 688 |
-
{/*
|
| 689 |
<div className="sidebar-section">
|
| 690 |
-
<h3>
|
| 691 |
-
<
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
</div>
|
| 700 |
|
| 701 |
-
{/* Visualization
|
| 702 |
<div className="sidebar-section">
|
| 703 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
| 704 |
-
<h3 style={{ margin: 0 }}>Visualization
|
| 705 |
<ThemeToggle />
|
| 706 |
</div>
|
| 707 |
|
| 708 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 709 |
-
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>View Mode</span>
|
| 710 |
<select
|
| 711 |
value={viewMode}
|
| 712 |
onChange={(e) => setViewMode(e.target.value as ViewMode)}
|
| 713 |
-
style={{ width: '100%', padding: '0.
|
|
|
|
| 714 |
>
|
| 715 |
-
<option value="3d">3D
|
| 716 |
-
<option value="scatter">2D
|
| 717 |
-
<option value="network">Network
|
| 718 |
<option value="distribution">Distribution</option>
|
| 719 |
-
<option value="stacked">Stacked</option>
|
| 720 |
-
<option value="heatmap">Heatmap</option>
|
| 721 |
</select>
|
| 722 |
-
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
|
| 723 |
-
{viewMode === '3d' && 'Interactive 3D exploration of model relationships'}
|
| 724 |
-
{viewMode === 'scatter' && '2D projection showing model similarity'}
|
| 725 |
-
{viewMode === 'network' && 'Network graph of model connections'}
|
| 726 |
-
{viewMode === 'distribution' && 'Statistical distributions of model properties'}
|
| 727 |
-
{viewMode === 'stacked' && 'Hierarchical view of model families'}
|
| 728 |
-
{viewMode === 'heatmap' && 'Density heatmap in latent space'}
|
| 729 |
-
</div>
|
| 730 |
</label>
|
| 731 |
|
| 732 |
-
{/*
|
| 733 |
-
{viewMode === '
|
| 734 |
-
<div style={{ marginBottom: '1rem' }}>
|
| 735 |
-
<RenderingStyleSelector />
|
| 736 |
-
</div>
|
| 737 |
-
)}
|
| 738 |
-
|
| 739 |
-
{/* Zoom and Label Controls for 3D View */}
|
| 740 |
-
{viewMode === '3d' && (
|
| 741 |
<>
|
| 742 |
<ZoomSlider
|
| 743 |
value={zoomLevel}
|
|
@@ -763,42 +854,38 @@ function App() {
|
|
| 763 |
)}
|
| 764 |
|
| 765 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 766 |
-
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>Color
|
| 767 |
<select
|
| 768 |
value={colorBy}
|
| 769 |
onChange={(e) => setColorBy(e.target.value as ColorByOption)}
|
| 770 |
-
style={{ width: '100%', padding: '0.
|
|
|
|
| 771 |
>
|
| 772 |
-
<option value="library_name">Library
|
| 773 |
-
<option value="pipeline_tag">Pipeline/Task
|
| 774 |
-
<option value="cluster_id">Cluster
|
| 775 |
-
<option value="family_depth">Family
|
| 776 |
-
<option value="downloads">
|
| 777 |
-
<option value="likes">
|
| 778 |
-
<option value="trending_score">Trending
|
| 779 |
-
<option value="licenses">License
|
| 780 |
</select>
|
| 781 |
-
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
|
| 782 |
-
{colorBy === 'cluster_id' && 'Semantic clusters from embeddings'}
|
| 783 |
-
{colorBy === 'family_depth' && 'Generation depth in family tree'}
|
| 784 |
-
{colorBy === 'licenses' && 'Model license types'}
|
| 785 |
-
</div>
|
| 786 |
</label>
|
| 787 |
|
| 788 |
-
{/* Color Scheme
|
| 789 |
{(colorBy === 'downloads' || colorBy === 'likes' || colorBy === 'family_depth' || colorBy === 'trending_score') && (
|
| 790 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 791 |
-
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>Color Scheme</span>
|
| 792 |
<select
|
| 793 |
value={colorScheme}
|
| 794 |
onChange={(e) => setColorScheme(e.target.value as any)}
|
| 795 |
-
style={{ width: '100%', padding: '0.
|
| 796 |
>
|
| 797 |
-
<option value="viridis">Viridis
|
| 798 |
-
<option value="plasma">Plasma
|
| 799 |
-
<option value="inferno">Inferno
|
| 800 |
-
<option value="magma">Magma
|
| 801 |
-
<option value="coolwarm">Cool-Warm
|
| 802 |
</select>
|
| 803 |
</label>
|
| 804 |
)}
|
|
@@ -815,156 +902,115 @@ function App() {
|
|
| 815 |
</label>
|
| 816 |
|
| 817 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 818 |
-
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>Size
|
| 819 |
<select
|
| 820 |
value={sizeBy}
|
| 821 |
onChange={(e) => setSizeBy(e.target.value as SizeByOption)}
|
| 822 |
-
style={{ width: '100%', padding: '0.
|
|
|
|
| 823 |
>
|
| 824 |
-
<option value="downloads">Downloads
|
| 825 |
-
<option value="likes">Likes
|
| 826 |
-
<option value="
|
| 827 |
-
<option value="none">Uniform Size</option>
|
| 828 |
</select>
|
| 829 |
</label>
|
| 830 |
|
| 831 |
-
<
|
| 832 |
-
<
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
<select
|
| 837 |
value={projectionMethod}
|
| 838 |
onChange={(e) => setProjectionMethod(e.target.value as 'umap' | 'tsne')}
|
| 839 |
-
style={{ width: '100%', padding: '0.5rem', borderRadius: '
|
|
|
|
| 840 |
>
|
| 841 |
-
<option value="umap">UMAP
|
| 842 |
-
<option value="tsne">t-SNE
|
| 843 |
</select>
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
<strong>t-SNE:</strong> Emphasizes local clusters, better for finding groups
|
| 847 |
-
</div>
|
| 848 |
-
</label>
|
| 849 |
-
</div>
|
| 850 |
</div>
|
| 851 |
|
| 852 |
-
{/*
|
| 853 |
-
<
|
| 854 |
-
<
|
|
|
|
|
|
|
| 855 |
|
| 856 |
-
<
|
| 857 |
-
<
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
<span style={{ fontWeight: '500' }}>Base Models Only</span>
|
| 865 |
-
|
| 866 |
-
Show only root models (no parent). Click any model to see its family tree.
|
| 867 |
-
</div>
|
| 868 |
-
</div>
|
| 869 |
-
</label>
|
| 870 |
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
}}
|
| 881 |
-
style={{ marginRight: '0.5rem', cursor: 'pointer' }}
|
| 882 |
-
/>
|
| 883 |
-
<div>
|
| 884 |
-
<span style={{ fontWeight: '500' }}>Semantic Similarity View</span>
|
| 885 |
-
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
|
| 886 |
-
Show models sorted by semantic similarity to a query model
|
| 887 |
-
</div>
|
| 888 |
-
</div>
|
| 889 |
-
</label>
|
| 890 |
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
{embeddingType && (
|
| 907 |
-
<div style={{
|
| 908 |
-
marginTop: '0.5rem',
|
| 909 |
-
padding: '0.75rem',
|
| 910 |
-
background: embeddingType === 'graph-aware' ? '#e8f5e9' : '#f5f5f5',
|
| 911 |
-
border: `1px solid ${embeddingType === 'graph-aware' ? '#4caf50' : '#d0d0d0'}`,
|
| 912 |
-
borderRadius: '4px',
|
| 913 |
-
fontSize: '0.75rem',
|
| 914 |
-
color: '#666'
|
| 915 |
-
}}>
|
| 916 |
-
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
| 917 |
-
<strong style={{ color: '#2d2d2d' }}>
|
| 918 |
-
{embeddingType === 'graph-aware' ? 'Graph-Aware' : 'Text-Only'} Embeddings
|
| 919 |
-
</strong>
|
| 920 |
-
</div>
|
| 921 |
-
<div style={{ fontSize: '0.7rem', color: '#666', lineHeight: '1.4' }}>
|
| 922 |
-
{embeddingType === 'graph-aware'
|
| 923 |
-
? 'Models in the same family tree are positioned closer together, revealing hierarchical relationships.'
|
| 924 |
-
: 'Standard text-based embeddings showing semantic similarity from model descriptions and tags.'}
|
| 925 |
-
</div>
|
| 926 |
-
</div>
|
| 927 |
-
)}
|
| 928 |
|
| 929 |
{semanticSimilarityMode && (
|
| 930 |
-
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '
|
| 931 |
-
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
|
| 932 |
-
Query Model ID
|
| 933 |
-
</label>
|
| 934 |
<input
|
| 935 |
type="text"
|
| 936 |
value={semanticQueryModel || ''}
|
| 937 |
onChange={(e) => setSemanticQueryModel(e.target.value || null)}
|
| 938 |
-
placeholder="
|
| 939 |
-
style={{ width: '100%', padding: '0.5rem', borderRadius: '
|
|
|
|
| 940 |
/>
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
</div>
|
| 961 |
-
</div>
|
| 962 |
</div>
|
| 963 |
)}
|
| 964 |
-
</
|
| 965 |
|
| 966 |
{/* Structural Visualization Options */}
|
| 967 |
-
{viewMode === '
|
| 968 |
<div className="sidebar-section">
|
| 969 |
<h3>Network Structure</h3>
|
| 970 |
<div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '1rem', lineHeight: '1.4' }}>
|
|
@@ -1002,14 +1048,14 @@ function App() {
|
|
| 1002 |
</label>
|
| 1003 |
|
| 1004 |
{showNetworkEdges && (
|
| 1005 |
-
<div style={{ marginLeft: '1.5rem', marginBottom: '1rem', padding: '0.75rem', background: 'white', borderRadius: '
|
| 1006 |
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>
|
| 1007 |
Connection Type
|
| 1008 |
</label>
|
| 1009 |
<select
|
| 1010 |
value={networkEdgeType}
|
| 1011 |
onChange={(e) => setNetworkEdgeType(e.target.value as 'library' | 'pipeline' | 'combined')}
|
| 1012 |
-
style={{ width: '100%', padding: '0.5rem', borderRadius: '
|
| 1013 |
>
|
| 1014 |
<option value="combined">Combined (library + pipeline + tags)</option>
|
| 1015 |
<option value="library">Library Only</option>
|
|
@@ -1035,131 +1081,90 @@ function App() {
|
|
| 1035 |
</div>
|
| 1036 |
)}
|
| 1037 |
|
| 1038 |
-
{/*
|
| 1039 |
-
<
|
| 1040 |
-
<
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
<button
|
| 1057 |
-
onClick={() => {
|
| 1058 |
-
setMinDownloads(10000);
|
| 1059 |
-
setMinLikes(10);
|
| 1060 |
-
}}
|
| 1061 |
-
className="btn btn-small"
|
| 1062 |
-
style={{
|
| 1063 |
-
background: '#fff3e0',
|
| 1064 |
-
color: '#e65100',
|
| 1065 |
-
border: '1px solid #ffb74d'
|
| 1066 |
-
}}
|
| 1067 |
-
>
|
| 1068 |
-
Popular Models
|
| 1069 |
-
</button>
|
| 1070 |
-
<button
|
| 1071 |
-
onClick={resetFilters}
|
| 1072 |
-
disabled={activeFilterCount === 0}
|
| 1073 |
-
className="btn btn-small btn-secondary"
|
| 1074 |
-
style={{
|
| 1075 |
-
opacity: activeFilterCount > 0 ? 1 : 0.5,
|
| 1076 |
-
cursor: activeFilterCount > 0 ? 'pointer' : 'not-allowed'
|
| 1077 |
-
}}
|
| 1078 |
-
>
|
| 1079 |
-
Reset All
|
| 1080 |
-
</button>
|
| 1081 |
-
</div>
|
| 1082 |
-
</div>
|
| 1083 |
-
|
| 1084 |
-
<div className="sidebar-section">
|
| 1085 |
-
<h3>Hierarchy Navigation</h3>
|
| 1086 |
-
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 1087 |
-
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
|
| 1088 |
-
Max Hierarchy Depth
|
| 1089 |
-
</span>
|
| 1090 |
-
<input
|
| 1091 |
-
type="range"
|
| 1092 |
-
min="0"
|
| 1093 |
-
max="10"
|
| 1094 |
-
value={maxHierarchyDepth ?? 10}
|
| 1095 |
-
onChange={(e) => {
|
| 1096 |
-
const val = parseInt(e.target.value);
|
| 1097 |
-
setMaxHierarchyDepth(val === 10 ? null : val);
|
| 1098 |
-
}}
|
| 1099 |
-
style={{ width: '100%', marginTop: '0.5rem' }}
|
| 1100 |
-
/>
|
| 1101 |
-
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem', display: 'flex', justifyContent: 'space-between' }}>
|
| 1102 |
-
<span>All levels</span>
|
| 1103 |
-
<span>{maxHierarchyDepth !== null ? `Depth ≤ ${maxHierarchyDepth}` : 'No limit'}</span>
|
| 1104 |
-
</div>
|
| 1105 |
-
</label>
|
| 1106 |
-
<label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 1107 |
-
<input
|
| 1108 |
-
type="checkbox"
|
| 1109 |
-
checked={showDistanceHeatmap}
|
| 1110 |
-
onChange={(e) => setShowDistanceHeatmap(e.target.checked)}
|
| 1111 |
-
/>
|
| 1112 |
-
<span style={{ fontSize: '0.9rem' }}>Show Distance Heatmap</span>
|
| 1113 |
-
</label>
|
| 1114 |
-
{selectedModel && (
|
| 1115 |
-
<div style={{ marginTop: '0.5rem', padding: '0.5rem', background: '#f5f5f5', borderRadius: '4px', fontSize: '0.85rem' }}>
|
| 1116 |
-
<div style={{ fontWeight: '500', marginBottom: '0.25rem' }}>Selected Model:</div>
|
| 1117 |
-
<div style={{ color: '#666', marginBottom: '0.5rem', wordBreak: 'break-word' }}>{selectedModel.model_id}</div>
|
| 1118 |
-
{selectedModel.family_depth !== null && (
|
| 1119 |
-
<div style={{ color: '#666', marginBottom: '0.5rem' }}>
|
| 1120 |
-
Hierarchy Depth: {selectedModel.family_depth}
|
| 1121 |
-
</div>
|
| 1122 |
-
)}
|
| 1123 |
-
<button
|
| 1124 |
-
onClick={() => {
|
| 1125 |
-
if (selectedModel.parent_model) {
|
| 1126 |
-
loadFamilyPath(selectedModel.model_id, selectedModel.parent_model);
|
| 1127 |
-
} else {
|
| 1128 |
-
loadFamilyPath(selectedModel.model_id);
|
| 1129 |
-
}
|
| 1130 |
-
}}
|
| 1131 |
-
style={{
|
| 1132 |
-
padding: '0.25rem 0.5rem',
|
| 1133 |
-
fontSize: '0.8rem',
|
| 1134 |
-
background: '#4a90e2',
|
| 1135 |
-
color: 'white',
|
| 1136 |
-
border: 'none',
|
| 1137 |
-
borderRadius: '2px',
|
| 1138 |
-
cursor: 'pointer',
|
| 1139 |
-
marginRight: '0.5rem',
|
| 1140 |
-
marginBottom: '0.5rem'
|
| 1141 |
-
}}
|
| 1142 |
-
>
|
| 1143 |
-
Show Path to Root
|
| 1144 |
-
</button>
|
| 1145 |
-
<button
|
| 1146 |
-
onClick={() => setHighlightedPath([])}
|
| 1147 |
-
style={{
|
| 1148 |
-
padding: '0.25rem 0.5rem',
|
| 1149 |
-
fontSize: '0.8rem',
|
| 1150 |
-
background: '#6a6a6a',
|
| 1151 |
-
color: 'white',
|
| 1152 |
-
border: 'none',
|
| 1153 |
-
borderRadius: '2px',
|
| 1154 |
-
cursor: 'pointer',
|
| 1155 |
-
marginBottom: '0.5rem'
|
| 1156 |
}}
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1163 |
|
| 1164 |
<div className="sidebar-section">
|
| 1165 |
<h3>Family Tree Explorer</h3>
|
|
@@ -1170,7 +1175,7 @@ function App() {
|
|
| 1170 |
onChange={(e) => setSearchInput(e.target.value)}
|
| 1171 |
onFocus={() => searchInput.length > 0 && setShowSearchResults(true)}
|
| 1172 |
placeholder="Type model name..."
|
| 1173 |
-
style={{ width: '100%', padding: '0.5rem', borderRadius: '
|
| 1174 |
/>
|
| 1175 |
{showSearchResults && searchResults.length > 0 && (
|
| 1176 |
<div style={{
|
|
@@ -1182,32 +1187,16 @@ function App() {
|
|
| 1182 |
border: '1px solid #d0d0d0',
|
| 1183 |
borderRadius: '2px',
|
| 1184 |
marginTop: '2px',
|
| 1185 |
-
maxHeight: '
|
| 1186 |
-
overflowY: 'auto',
|
| 1187 |
zIndex: 1000,
|
| 1188 |
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
| 1189 |
}}>
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
style={{
|
| 1197 |
-
padding: '0.5rem',
|
| 1198 |
-
cursor: 'pointer',
|
| 1199 |
-
borderBottom: '1px solid #f0f0f0',
|
| 1200 |
-
fontSize: '0.85rem'
|
| 1201 |
-
}}
|
| 1202 |
-
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f5f5'}
|
| 1203 |
-
onMouseLeave={(e) => e.currentTarget.style.background = 'white'}
|
| 1204 |
-
>
|
| 1205 |
-
<div style={{ fontWeight: '500' }}>{result.model_id}</div>
|
| 1206 |
-
{result.library_name && (
|
| 1207 |
-
<div style={{ fontSize: '0.75rem', color: '#666' }}>{result.library_name}</div>
|
| 1208 |
-
)}
|
| 1209 |
-
</div>
|
| 1210 |
-
))}
|
| 1211 |
</div>
|
| 1212 |
)}
|
| 1213 |
</div>
|
|
@@ -1239,7 +1228,7 @@ function App() {
|
|
| 1239 |
|
| 1240 |
{/* Bookmarks */}
|
| 1241 |
{bookmarkedModels.length > 0 && (
|
| 1242 |
-
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '
|
| 1243 |
<h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Bookmarks ({bookmarkedModels.length})</h3>
|
| 1244 |
<div style={{ maxHeight: '150px', overflowY: 'auto', fontSize: '0.85rem' }}>
|
| 1245 |
{bookmarkedModels.map(modelId => (
|
|
@@ -1265,10 +1254,10 @@ function App() {
|
|
| 1265 |
|
| 1266 |
{/* Comparison */}
|
| 1267 |
{comparisonModels.length > 0 && (
|
| 1268 |
-
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '
|
| 1269 |
<h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Comparison ({comparisonModels.length}/3)</h3>
|
| 1270 |
{comparisonModels.map(model => (
|
| 1271 |
-
<div key={model.model_id} style={{ marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '
|
| 1272 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 1273 |
<strong>{model.model_id}</strong>
|
| 1274 |
<button
|
|
@@ -1295,7 +1284,7 @@ function App() {
|
|
| 1295 |
|
| 1296 |
{/* Similar Models */}
|
| 1297 |
{showSimilar && similarModels.length > 0 && (
|
| 1298 |
-
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '
|
| 1299 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
| 1300 |
<h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Similar Models</h3>
|
| 1301 |
<button
|
|
@@ -1307,7 +1296,7 @@ function App() {
|
|
| 1307 |
</div>
|
| 1308 |
<div style={{ maxHeight: '200px', overflowY: 'auto', fontSize: '0.85rem' }}>
|
| 1309 |
{similarModels.map((similar, idx) => (
|
| 1310 |
-
<div key={idx} style={{ marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '
|
| 1311 |
<div style={{ fontWeight: '500' }}>{similar.model_id}</div>
|
| 1312 |
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
|
| 1313 |
Similarity: {(similar.similarity * 100).toFixed(1)}% | Distance: {similar.distance.toFixed(3)}
|
|
@@ -1324,7 +1313,7 @@ function App() {
|
|
| 1324 |
|
| 1325 |
|
| 1326 |
{selectedModels.length > 0 && (
|
| 1327 |
-
<div style={{ marginTop: '1rem', padding: '0.5rem', background: '#e3f2fd', borderRadius: '
|
| 1328 |
<strong>Selected: {selectedModels.length} models</strong>
|
| 1329 |
<button
|
| 1330 |
onClick={() => setSelectedModels([])}
|
|
@@ -1344,113 +1333,8 @@ function App() {
|
|
| 1344 |
)}
|
| 1345 |
{!loading && !error && data.length > 0 && (
|
| 1346 |
<>
|
| 1347 |
-
{viewMode === '3d' && (
|
| 1348 |
-
<div style={{ display: 'flex', gap: '10px', width: '100%', height: '100%' }}>
|
| 1349 |
-
<div
|
| 1350 |
-
style={{ flex: 1, position: 'relative' }}
|
| 1351 |
-
onMouseMove={(e) => {
|
| 1352 |
-
setTooltipPosition({ x: e.clientX, y: e.clientY });
|
| 1353 |
-
}}
|
| 1354 |
-
onMouseLeave={() => {
|
| 1355 |
-
setHoveredModel(null);
|
| 1356 |
-
setTooltipPosition(null);
|
| 1357 |
-
}}
|
| 1358 |
-
>
|
| 1359 |
-
<Suspense fallback={<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#666' }}>Loading 3D visualization...</div>}>
|
| 1360 |
-
<ScatterPlot3D
|
| 1361 |
-
width={width * 0.8}
|
| 1362 |
-
height={height}
|
| 1363 |
-
data={data}
|
| 1364 |
-
familyTree={familyTree.length > 0 ? familyTree : undefined}
|
| 1365 |
-
colorBy={colorBy}
|
| 1366 |
-
sizeBy={sizeBy}
|
| 1367 |
-
colorScheme={colorScheme}
|
| 1368 |
-
showLegend={showLegend}
|
| 1369 |
-
showLabels={showLabels}
|
| 1370 |
-
zoomLevel={zoomLevel}
|
| 1371 |
-
nodeDensity={nodeDensity}
|
| 1372 |
-
renderingStyle={renderingStyle}
|
| 1373 |
-
showNetworkEdges={showNetworkEdges}
|
| 1374 |
-
showStructuralGroups={showStructuralGroups}
|
| 1375 |
-
overviewMode={overviewMode}
|
| 1376 |
-
networkEdgeType={networkEdgeType}
|
| 1377 |
-
onPointClick={(model) => {
|
| 1378 |
-
setSelectedModel(model);
|
| 1379 |
-
setIsModalOpen(true);
|
| 1380 |
-
}}
|
| 1381 |
-
selectedModelId={selectedModel?.model_id || familyTreeModelId}
|
| 1382 |
-
selectedModel={selectedModel}
|
| 1383 |
-
onViewChange={setViewCenter}
|
| 1384 |
-
targetViewCenter={viewCenter}
|
| 1385 |
-
onHover={(model, pointer) => {
|
| 1386 |
-
setHoveredModel(model);
|
| 1387 |
-
if (model && pointer) {
|
| 1388 |
-
setTooltipPosition(pointer);
|
| 1389 |
-
} else {
|
| 1390 |
-
setTooltipPosition(null);
|
| 1391 |
-
}
|
| 1392 |
-
}}
|
| 1393 |
-
highlightedPath={highlightedPath}
|
| 1394 |
-
showDistanceHeatmap={showDistanceHeatmap && !!selectedModel}
|
| 1395 |
-
/>
|
| 1396 |
-
</Suspense>
|
| 1397 |
-
<ModelTooltip
|
| 1398 |
-
model={hoveredModel}
|
| 1399 |
-
position={tooltipPosition}
|
| 1400 |
-
visible={!!hoveredModel && !!tooltipPosition}
|
| 1401 |
-
/>
|
| 1402 |
-
</div>
|
| 1403 |
-
<div style={{ width: width * 0.2, height: height, display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
| 1404 |
-
<div style={{
|
| 1405 |
-
width: width * 0.2 - 20,
|
| 1406 |
-
padding: '8px',
|
| 1407 |
-
background: '#f5f5f5',
|
| 1408 |
-
borderRadius: '2px',
|
| 1409 |
-
border: '1px solid #d0d0d0',
|
| 1410 |
-
fontSize: '10px',
|
| 1411 |
-
fontFamily: "'Instrument Sans', sans-serif"
|
| 1412 |
-
}}>
|
| 1413 |
-
<h4 style={{ marginTop: 0, marginBottom: '0.5rem', fontSize: '11px', fontWeight: '600' }}>UV Projection</h4>
|
| 1414 |
-
<p style={{ margin: 0, lineHeight: '1.3', color: '#666', fontSize: '9px' }}>
|
| 1415 |
-
This 2D projection shows the XY plane of the latent space. Click on any point to navigate the 3D view to that region.
|
| 1416 |
-
</p>
|
| 1417 |
-
</div>
|
| 1418 |
-
<UVProjectionSquare
|
| 1419 |
-
width={width * 0.2 - 20}
|
| 1420 |
-
height={height * 0.3}
|
| 1421 |
-
data={data}
|
| 1422 |
-
familyTree={familyTree.length > 0 ? familyTree : undefined}
|
| 1423 |
-
colorBy={colorBy}
|
| 1424 |
-
onRegionSelect={(center: { x: number; y: number; z: number }) => {
|
| 1425 |
-
setViewCenter(center);
|
| 1426 |
-
// Camera will automatically animate to this position via targetViewCenter prop
|
| 1427 |
-
}}
|
| 1428 |
-
selectedModelId={selectedModel?.model_id || familyTreeModelId}
|
| 1429 |
-
currentViewCenter={viewCenter}
|
| 1430 |
-
/>
|
| 1431 |
-
{viewCenter && (
|
| 1432 |
-
<div style={{
|
| 1433 |
-
width: width * 0.2 - 20,
|
| 1434 |
-
padding: '8px',
|
| 1435 |
-
background: '#f5f5f5',
|
| 1436 |
-
borderRadius: '2px',
|
| 1437 |
-
border: '1px solid #d0d0d0',
|
| 1438 |
-
fontSize: '10px',
|
| 1439 |
-
fontFamily: "'Instrument Sans', sans-serif"
|
| 1440 |
-
}}>
|
| 1441 |
-
<strong style={{ fontSize: '10px' }}>View Center:</strong>
|
| 1442 |
-
<div style={{ fontSize: '9px', marginTop: '0.25rem', color: '#666' }}>
|
| 1443 |
-
X: {viewCenter.x.toFixed(3)}<br />
|
| 1444 |
-
Y: {viewCenter.y.toFixed(3)}<br />
|
| 1445 |
-
Z: {viewCenter.z.toFixed(3)}
|
| 1446 |
-
</div>
|
| 1447 |
-
</div>
|
| 1448 |
-
)}
|
| 1449 |
-
</div>
|
| 1450 |
-
</div>
|
| 1451 |
-
)}
|
| 1452 |
{viewMode === 'scatter' && (
|
| 1453 |
-
<
|
| 1454 |
width={width}
|
| 1455 |
height={height}
|
| 1456 |
data={data}
|
|
@@ -1465,6 +1349,26 @@ function App() {
|
|
| 1465 |
}}
|
| 1466 |
/>
|
| 1467 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1468 |
{viewMode === 'network' && (
|
| 1469 |
<NetworkGraph
|
| 1470 |
width={width}
|
|
@@ -1479,12 +1383,6 @@ function App() {
|
|
| 1479 |
{viewMode === 'distribution' && (
|
| 1480 |
<DistributionView data={data} width={width} height={height} />
|
| 1481 |
)}
|
| 1482 |
-
{viewMode === 'stacked' && (
|
| 1483 |
-
<StackedView data={data} width={width} height={height} />
|
| 1484 |
-
)}
|
| 1485 |
-
{viewMode === 'heatmap' && (
|
| 1486 |
-
<HeatmapView data={data} width={width} height={height} />
|
| 1487 |
-
)}
|
| 1488 |
</>
|
| 1489 |
)}
|
| 1490 |
</main>
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
| 2 |
// Visualizations
|
| 3 |
+
import ScatterPlot from './components/visualizations/ScatterPlot';
|
| 4 |
+
import ScatterPlot3D from './components/visualizations/ScatterPlot3D';
|
| 5 |
import NetworkGraph from './components/visualizations/NetworkGraph';
|
|
|
|
| 6 |
import DistributionView from './components/visualizations/DistributionView';
|
|
|
|
|
|
|
| 7 |
// Controls
|
| 8 |
import RandomModelButton from './components/controls/RandomModelButton';
|
| 9 |
import ZoomSlider from './components/controls/ZoomSlider';
|
| 10 |
import ThemeToggle from './components/controls/ThemeToggle';
|
| 11 |
+
// import RenderingStyleSelector from './components/controls/RenderingStyleSelector';
|
| 12 |
+
// import VisualizationModeButtons from './components/controls/VisualizationModeButtons';
|
| 13 |
+
// import ClusterFilter from './components/controls/ClusterFilter';
|
| 14 |
+
import type { Cluster } from './components/controls/ClusterFilter';
|
| 15 |
import NodeDensitySlider from './components/controls/NodeDensitySlider';
|
| 16 |
// Modals
|
| 17 |
import ModelModal from './components/modals/ModelModal';
|
| 18 |
// UI Components
|
| 19 |
import LiveModelCount from './components/ui/LiveModelCount';
|
| 20 |
+
// import ModelTooltip from './components/ui/ModelTooltip';
|
| 21 |
import ErrorBoundary from './components/ui/ErrorBoundary';
|
| 22 |
+
import VirtualSearchResults from './components/ui/VirtualSearchResults';
|
| 23 |
// Types & Utils
|
| 24 |
import { ModelPoint, Stats, FamilyTree, SearchResult, SimilarModel } from './types';
|
| 25 |
import cache, { IndexedDBCache } from './utils/data/indexedDB';
|
| 26 |
import { debounce } from './utils/debounce';
|
| 27 |
import requestManager from './utils/api/requestManager';
|
| 28 |
+
import { fetchWithMsgPack, decodeModelsMsgPack } from './utils/api/msgpackDecoder';
|
| 29 |
import { useFilterStore, ViewMode, ColorByOption, SizeByOption } from './stores/filterStore';
|
| 30 |
import { API_BASE } from './config/api';
|
| 31 |
import './App.css';
|
|
|
|
| 38 |
},
|
| 39 |
};
|
| 40 |
|
|
|
|
|
|
|
| 41 |
function App() {
|
| 42 |
// Filter store state
|
| 43 |
const {
|
|
|
|
| 47 |
colorScheme,
|
| 48 |
showLabels,
|
| 49 |
zoomLevel,
|
| 50 |
+
// nodeDensity,
|
| 51 |
+
// renderingStyle,
|
| 52 |
theme,
|
| 53 |
selectedClusters,
|
| 54 |
searchQuery,
|
|
|
|
| 60 |
setColorScheme,
|
| 61 |
setShowLabels,
|
| 62 |
setZoomLevel,
|
| 63 |
+
// setNodeDensity,
|
| 64 |
+
// setRenderingStyle,
|
| 65 |
+
// setSelectedClusters,
|
| 66 |
setSearchQuery,
|
| 67 |
setMinDownloads,
|
| 68 |
setMinLikes,
|
|
|
|
| 92 |
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
| 93 |
const [searchInput, setSearchInput] = useState('');
|
| 94 |
const [showSearchResults, setShowSearchResults] = useState(false);
|
| 95 |
+
// const [viewCenter, setViewCenter] = useState<{ x: number; y: number; z: number } | null>(null);
|
| 96 |
const [projectionMethod, setProjectionMethod] = useState<'umap' | 'tsne'>('umap');
|
| 97 |
const [bookmarkedModels, setBookmarkedModels] = useState<string[]>([]);
|
| 98 |
const [comparisonModels, setComparisonModels] = useState<ModelPoint[]>([]);
|
|
|
|
| 177 |
projection_method: projectionMethod,
|
| 178 |
});
|
| 179 |
const url = `${API_BASE}/api/models/semantic-similarity?${params}`;
|
| 180 |
+
// Try MessagePack first, fallback to JSON
|
| 181 |
+
try {
|
| 182 |
+
const result = await fetchWithMsgPack<{ models: ModelPoint[] }>(url);
|
| 183 |
+
models = result.models || [];
|
| 184 |
+
count = models.length;
|
| 185 |
+
} catch (msgpackError) {
|
| 186 |
+
// Fallback to JSON
|
| 187 |
+
const response = await requestManager.fetch(url, {}, cacheKey);
|
| 188 |
+
if (!response.ok) throw new Error('Failed to fetch similar models');
|
| 189 |
+
const result = await response.json();
|
| 190 |
+
models = result.models || [];
|
| 191 |
+
count = models.length;
|
| 192 |
+
}
|
| 193 |
} else {
|
| 194 |
const params = new URLSearchParams({
|
| 195 |
min_downloads: minDownloads.toString(),
|
|
|
|
| 204 |
params.append('search_query', searchQuery);
|
| 205 |
}
|
| 206 |
|
| 207 |
+
params.append('max_points', viewMode === '3d' ? '50000' : viewMode === 'scatter' ? '10000' : viewMode === 'network' ? '500' : '5000');
|
| 208 |
+
// Add format parameter for MessagePack support
|
| 209 |
+
params.append('format', 'msgpack');
|
| 210 |
|
| 211 |
const url = `${API_BASE}/api/models?${params}`;
|
| 212 |
+
// Try MessagePack first for better performance, fallback to JSON
|
| 213 |
+
try {
|
| 214 |
+
const response = await requestManager.fetch(url, {
|
| 215 |
+
headers: {
|
| 216 |
+
'Accept': 'application/msgpack',
|
| 217 |
+
},
|
| 218 |
+
}, cacheKey);
|
| 219 |
+
|
| 220 |
+
if (!response.ok) throw new Error('Failed to fetch models');
|
| 221 |
+
|
| 222 |
+
const contentType = response.headers.get('content-type');
|
| 223 |
+
if (contentType?.includes('application/msgpack')) {
|
| 224 |
+
// Decode MessagePack binary response (backend returns compact format array)
|
| 225 |
+
const buffer = await response.arrayBuffer();
|
| 226 |
+
models = decodeModelsMsgPack(new Uint8Array(buffer));
|
| 227 |
+
count = models.length;
|
| 228 |
+
setEmbeddingType('text-only'); // MessagePack response doesn't include metadata
|
| 229 |
+
} else {
|
| 230 |
+
// Response was JSON (backend may not support msgpack or returned JSON)
|
| 231 |
+
const result = await response.json();
|
| 232 |
+
if (Array.isArray(result)) {
|
| 233 |
+
models = result;
|
| 234 |
+
count = models.length;
|
| 235 |
+
setEmbeddingType('text-only');
|
| 236 |
+
} else {
|
| 237 |
+
models = result.models || [];
|
| 238 |
+
count = result.filtered_count ?? models.length;
|
| 239 |
+
setEmbeddingType(result.embedding_type || 'text-only');
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
} catch (error) {
|
| 243 |
+
// Fallback to JSON if MessagePack fails
|
| 244 |
+
const jsonUrl = url.replace('format=msgpack', 'format=json');
|
| 245 |
+
const response = await requestManager.fetch(jsonUrl, {}, cacheKey);
|
| 246 |
+
if (!response.ok) throw new Error('Failed to fetch models');
|
| 247 |
+
const result = await response.json();
|
| 248 |
+
|
| 249 |
+
if (Array.isArray(result)) {
|
| 250 |
+
models = result;
|
| 251 |
+
count = models.length;
|
| 252 |
+
setEmbeddingType('text-only');
|
| 253 |
+
} else {
|
| 254 |
+
models = result.models || [];
|
| 255 |
+
count = result.filtered_count ?? models.length;
|
| 256 |
+
setEmbeddingType(result.embedding_type || 'text-only');
|
| 257 |
+
}
|
| 258 |
}
|
| 259 |
}
|
| 260 |
|
|
|
|
| 276 |
setLoading(false);
|
| 277 |
fetchDataAbortRef.current = null;
|
| 278 |
}
|
| 279 |
+
}, [minDownloads, minLikes, searchQuery, colorBy, sizeBy, projectionMethod, baseModelsOnly, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode]);
|
| 280 |
|
| 281 |
const debouncedFetchData = useMemo(
|
| 282 |
() => debounce(fetchData, 300),
|
|
|
|
| 301 |
};
|
| 302 |
}, [minDownloads, minLikes, colorBy, sizeBy, baseModelsOnly, projectionMethod, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, debouncedFetchData]);
|
| 303 |
|
| 304 |
+
// Function to clear cache and refresh stats
|
| 305 |
+
const clearCacheAndRefresh = useCallback(async () => {
|
| 306 |
+
try {
|
| 307 |
+
// Clear all caches
|
| 308 |
+
await cache.clear('stats');
|
| 309 |
+
await cache.clear('models');
|
| 310 |
+
console.log('Cache cleared successfully');
|
| 311 |
+
|
| 312 |
+
// Immediately fetch fresh stats
|
| 313 |
+
const response = await fetch(`${API_BASE}/api/stats`);
|
| 314 |
+
if (!response.ok) throw new Error('Failed to fetch stats');
|
| 315 |
+
const statsData = await response.json();
|
| 316 |
+
await cache.cacheStats('stats', statsData);
|
| 317 |
+
setStats(statsData);
|
| 318 |
+
|
| 319 |
+
// Refresh model data
|
| 320 |
+
fetchData();
|
| 321 |
+
} catch (err) {
|
| 322 |
+
if (err instanceof Error) {
|
| 323 |
+
logger.error('Error clearing cache:', err);
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
}, [fetchData]);
|
| 327 |
+
|
| 328 |
useEffect(() => {
|
| 329 |
const fetchStats = async () => {
|
| 330 |
const cacheKey = 'stats';
|
| 331 |
const cachedStats = await cache.getCachedStats(cacheKey);
|
| 332 |
+
|
| 333 |
+
// Always fetch fresh stats on initial load, ignore stale cache
|
| 334 |
+
// This fixes the issue of showing old data (1000 models) when backend has 5000
|
| 335 |
if (cachedStats) {
|
| 336 |
+
// Show cached data temporarily
|
| 337 |
setStats(cachedStats);
|
|
|
|
| 338 |
}
|
| 339 |
+
|
| 340 |
+
// Always fetch fresh stats to update
|
| 341 |
try {
|
| 342 |
const response = await fetch(`${API_BASE}/api/stats`);
|
| 343 |
if (!response.ok) throw new Error('Failed to fetch stats');
|
|
|
|
| 522 |
<ErrorBoundary>
|
| 523 |
<div className="App">
|
| 524 |
<header className="App-header">
|
| 525 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem', width: '100%' }}>
|
| 526 |
+
<div style={{ flex: '1 1 auto', minWidth: '250px' }}>
|
| 527 |
+
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: '600', lineHeight: '1.2' }}>ML Ecosystem: 2M Models on Hugging Face</h1>
|
| 528 |
+
<div style={{ marginTop: '0.5rem', fontSize: '0.85rem', opacity: 0.9, display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
| 529 |
+
<a href="https://arxiv.org/abs/2508.06811" target="_blank" rel="noopener noreferrer" style={{ color: '#64b5f6', textDecoration: 'none', whiteSpace: 'nowrap' }}>Paper</a>
|
| 530 |
+
<a href="https://github.com/bendlaufer/ai-ecosystem" target="_blank" rel="noopener noreferrer" style={{ color: '#64b5f6', textDecoration: 'none', whiteSpace: 'nowrap' }}>GitHub</a>
|
| 531 |
+
<a href="https://huggingface.co/modelbiome" target="_blank" rel="noopener noreferrer" style={{ color: '#64b5f6', textDecoration: 'none', whiteSpace: 'nowrap' }}>Dataset</a>
|
| 532 |
+
<span style={{ opacity: 0.7, whiteSpace: 'nowrap' }}>Laufer, Oderinwale, Kleinberg</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
</div>
|
| 534 |
+
</div>
|
| 535 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap', flexShrink: 0 }}>
|
| 536 |
+
<LiveModelCount compact={true} />
|
| 537 |
+
{stats && (
|
| 538 |
+
<>
|
| 539 |
+
<div className="stats" style={{ display: 'flex', gap: '0.5rem', fontSize: '0.8rem', flexWrap: 'wrap' }}>
|
| 540 |
+
<span>{stats.total_models.toLocaleString()} models</span>
|
| 541 |
+
<span>{stats.unique_libraries} libraries</span>
|
| 542 |
+
</div>
|
| 543 |
+
<button
|
| 544 |
+
onClick={clearCacheAndRefresh}
|
| 545 |
+
style={{
|
| 546 |
+
background: 'rgba(255, 255, 255, 0.15)',
|
| 547 |
+
border: '1px solid rgba(255, 255, 255, 0.3)',
|
| 548 |
+
borderRadius: '0',
|
| 549 |
+
width: '32px',
|
| 550 |
+
height: '32px',
|
| 551 |
+
cursor: 'pointer',
|
| 552 |
+
display: 'flex',
|
| 553 |
+
alignItems: 'center',
|
| 554 |
+
justifyContent: 'center',
|
| 555 |
+
fontSize: '16px',
|
| 556 |
+
transition: 'all 0.2s ease',
|
| 557 |
+
flexShrink: 0,
|
| 558 |
+
}}
|
| 559 |
+
onMouseOver={(e) => {
|
| 560 |
+
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
| 561 |
+
}}
|
| 562 |
+
onMouseOut={(e) => {
|
| 563 |
+
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
| 564 |
+
}}
|
| 565 |
+
title="Refresh data and clear cache"
|
| 566 |
+
aria-label="Refresh data"
|
| 567 |
+
>
|
| 568 |
+
⟳
|
| 569 |
+
</button>
|
| 570 |
+
</>
|
| 571 |
+
)}
|
| 572 |
+
</div>
|
| 573 |
</div>
|
| 574 |
</header>
|
| 575 |
|
|
|
|
| 597 |
background: '#4a4a4a',
|
| 598 |
color: 'white',
|
| 599 |
padding: '0.35rem 0.7rem',
|
| 600 |
+
borderRadius: '0',
|
| 601 |
fontWeight: '600'
|
| 602 |
}}>
|
| 603 |
{activeFilterCount} active
|
|
|
|
| 628 |
background: '#4a4a4a',
|
| 629 |
color: 'white',
|
| 630 |
padding: '0.3rem 0.6rem',
|
| 631 |
+
borderRadius: '0',
|
| 632 |
fontWeight: '600'
|
| 633 |
}}>
|
| 634 |
Graph
|
|
|
|
| 650 |
|
| 651 |
{/* Search Section */}
|
| 652 |
<div className="sidebar-section">
|
| 653 |
+
<h3>Search</h3>
|
| 654 |
<input
|
| 655 |
type="text"
|
| 656 |
value={searchQuery}
|
| 657 |
onChange={(e) => setSearchQuery(e.target.value)}
|
| 658 |
+
placeholder="Search models, tags, libraries..."
|
| 659 |
style={{ width: '100%' }}
|
| 660 |
+
title="Search by model name, tags, library, or metadata"
|
| 661 |
/>
|
|
|
|
|
|
|
|
|
|
| 662 |
</div>
|
| 663 |
|
| 664 |
{/* Popularity Filters */}
|
|
|
|
| 712 |
</label>
|
| 713 |
</div>
|
| 714 |
|
| 715 |
+
{/* License Filter - Collapsed */}
|
| 716 |
{stats && stats.licenses && typeof stats.licenses === 'object' && Object.keys(stats.licenses).length > 0 && (
|
| 717 |
+
<details className="sidebar-section" style={{ border: '1px solid #e0e0e0', borderRadius: '0', padding: '0.75rem' }}>
|
| 718 |
+
<summary style={{ cursor: 'pointer', fontWeight: '600', fontSize: '0.95rem', listStyle: 'none', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 719 |
+
<span>Licenses ({Object.keys(stats.licenses).length})</span>
|
| 720 |
+
</summary>
|
| 721 |
+
<div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '1rem' }}>
|
| 722 |
{Object.entries(stats.licenses as Record<string, number>)
|
| 723 |
+
.sort((a, b) => b[1] - a[1])
|
| 724 |
+
.slice(0, 20)
|
| 725 |
.map(([license, count]) => (
|
| 726 |
<label
|
| 727 |
key={license}
|
| 728 |
style={{
|
| 729 |
display: 'flex',
|
| 730 |
alignItems: 'center',
|
| 731 |
+
gap: '0.4rem',
|
| 732 |
+
marginBottom: '0.4rem',
|
| 733 |
cursor: 'pointer',
|
| 734 |
+
fontSize: '0.85rem'
|
| 735 |
}}
|
| 736 |
>
|
| 737 |
<input
|
|
|
|
| 739 |
checked={searchQuery.toLowerCase().includes(license.toLowerCase())}
|
| 740 |
onChange={(e) => {
|
| 741 |
if (e.target.checked) {
|
|
|
|
| 742 |
setSearchQuery(searchQuery ? `${searchQuery} ${license}` : license);
|
| 743 |
} else {
|
|
|
|
| 744 |
setSearchQuery(searchQuery.replace(license, '').trim() || '');
|
| 745 |
}
|
| 746 |
}}
|
| 747 |
/>
|
| 748 |
<span style={{ flex: 1 }}>{license || 'Unknown'}</span>
|
| 749 |
+
<span style={{ fontSize: '0.7rem', color: '#999' }}>({Number(count).toLocaleString()})</span>
|
| 750 |
</label>
|
| 751 |
))}
|
| 752 |
</div>
|
| 753 |
+
</details>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
)}
|
| 755 |
|
| 756 |
+
{/* Quick Actions - Consolidated */}
|
| 757 |
<div className="sidebar-section">
|
| 758 |
+
<h3>Quick Actions</h3>
|
| 759 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
| 760 |
+
<RandomModelButton
|
| 761 |
+
data={data}
|
| 762 |
+
onSelect={(model: ModelPoint) => {
|
| 763 |
+
setSelectedModel(model);
|
| 764 |
+
setIsModalOpen(true);
|
| 765 |
+
}}
|
| 766 |
+
disabled={loading || data.length === 0}
|
| 767 |
+
/>
|
| 768 |
+
<button
|
| 769 |
+
onClick={() => {
|
| 770 |
+
const avgDownloads = data.reduce((sum, m) => sum + (m.downloads || 0), 0) / data.length;
|
| 771 |
+
setMinDownloads(Math.floor(avgDownloads));
|
| 772 |
+
}}
|
| 773 |
+
disabled={loading || data.length === 0}
|
| 774 |
+
style={{
|
| 775 |
+
padding: '0.75rem',
|
| 776 |
+
background: '#4a90e2',
|
| 777 |
+
color: 'white',
|
| 778 |
+
border: 'none',
|
| 779 |
+
borderRadius: '0',
|
| 780 |
+
cursor: loading || data.length === 0 ? 'not-allowed' : 'pointer',
|
| 781 |
+
fontWeight: '500',
|
| 782 |
+
fontSize: '0.9rem',
|
| 783 |
+
opacity: loading || data.length === 0 ? 0.5 : 1
|
| 784 |
+
}}
|
| 785 |
+
title="Filter to models with above average downloads"
|
| 786 |
+
>
|
| 787 |
+
Popular Models
|
| 788 |
+
</button>
|
| 789 |
+
<button
|
| 790 |
+
onClick={resetFilters}
|
| 791 |
+
style={{
|
| 792 |
+
padding: '0.75rem',
|
| 793 |
+
background: '#6c757d',
|
| 794 |
+
color: 'white',
|
| 795 |
+
border: 'none',
|
| 796 |
+
borderRadius: '0',
|
| 797 |
+
cursor: 'pointer',
|
| 798 |
+
fontWeight: '500',
|
| 799 |
+
fontSize: '0.9rem'
|
| 800 |
+
}}
|
| 801 |
+
title="Clear all filters and reset to defaults"
|
| 802 |
+
>
|
| 803 |
+
Reset All
|
| 804 |
+
</button>
|
| 805 |
+
</div>
|
| 806 |
</div>
|
| 807 |
|
| 808 |
+
{/* Visualization */}
|
| 809 |
<div className="sidebar-section">
|
| 810 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
| 811 |
+
<h3 style={{ margin: 0 }}>Visualization</h3>
|
| 812 |
<ThemeToggle />
|
| 813 |
</div>
|
| 814 |
|
| 815 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 816 |
+
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>View Mode</span>
|
| 817 |
<select
|
| 818 |
value={viewMode}
|
| 819 |
onChange={(e) => setViewMode(e.target.value as ViewMode)}
|
| 820 |
+
style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
|
| 821 |
+
title="Choose how to visualize the models"
|
| 822 |
>
|
| 823 |
+
<option value="3d">3D Scatter</option>
|
| 824 |
+
<option value="scatter">2D Scatter</option>
|
| 825 |
+
<option value="network">Network</option>
|
| 826 |
<option value="distribution">Distribution</option>
|
|
|
|
|
|
|
| 827 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 828 |
</label>
|
| 829 |
|
| 830 |
+
{/* Zoom and Label Controls for Scatter View */}
|
| 831 |
+
{viewMode === 'scatter' && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
<>
|
| 833 |
<ZoomSlider
|
| 834 |
value={zoomLevel}
|
|
|
|
| 854 |
)}
|
| 855 |
|
| 856 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 857 |
+
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>Color By</span>
|
| 858 |
<select
|
| 859 |
value={colorBy}
|
| 860 |
onChange={(e) => setColorBy(e.target.value as ColorByOption)}
|
| 861 |
+
style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
|
| 862 |
+
title="Choose what attribute to color models by"
|
| 863 |
>
|
| 864 |
+
<option value="library_name">Library</option>
|
| 865 |
+
<option value="pipeline_tag">Pipeline/Task</option>
|
| 866 |
+
<option value="cluster_id">Cluster</option>
|
| 867 |
+
<option value="family_depth">Family Depth</option>
|
| 868 |
+
<option value="downloads">Downloads</option>
|
| 869 |
+
<option value="likes">Likes</option>
|
| 870 |
+
<option value="trending_score">Trending</option>
|
| 871 |
+
<option value="licenses">License</option>
|
| 872 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
</label>
|
| 874 |
|
| 875 |
+
{/* Color Scheme */}
|
| 876 |
{(colorBy === 'downloads' || colorBy === 'likes' || colorBy === 'family_depth' || colorBy === 'trending_score') && (
|
| 877 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 878 |
+
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>Color Scheme</span>
|
| 879 |
<select
|
| 880 |
value={colorScheme}
|
| 881 |
onChange={(e) => setColorScheme(e.target.value as any)}
|
| 882 |
+
style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
|
| 883 |
>
|
| 884 |
+
<option value="viridis">Viridis</option>
|
| 885 |
+
<option value="plasma">Plasma</option>
|
| 886 |
+
<option value="inferno">Inferno</option>
|
| 887 |
+
<option value="magma">Magma</option>
|
| 888 |
+
<option value="coolwarm">Cool-Warm</option>
|
| 889 |
</select>
|
| 890 |
</label>
|
| 891 |
)}
|
|
|
|
| 902 |
</label>
|
| 903 |
|
| 904 |
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 905 |
+
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>Size By</span>
|
| 906 |
<select
|
| 907 |
value={sizeBy}
|
| 908 |
onChange={(e) => setSizeBy(e.target.value as SizeByOption)}
|
| 909 |
+
style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
|
| 910 |
+
title="Choose what determines point size"
|
| 911 |
>
|
| 912 |
+
<option value="downloads">Downloads</option>
|
| 913 |
+
<option value="likes">Likes</option>
|
| 914 |
+
<option value="none">Uniform</option>
|
|
|
|
| 915 |
</select>
|
| 916 |
</label>
|
| 917 |
|
| 918 |
+
<details style={{ marginBottom: '1rem', marginTop: '1rem' }}>
|
| 919 |
+
<summary style={{ cursor: 'pointer', fontWeight: '500', fontSize: '0.9rem', marginBottom: '0rem' }}>
|
| 920 |
+
Advanced Settings
|
| 921 |
+
</summary>
|
| 922 |
+
<div style={{ marginTop: '0.75rem' }}>
|
| 923 |
<select
|
| 924 |
value={projectionMethod}
|
| 925 |
onChange={(e) => setProjectionMethod(e.target.value as 'umap' | 'tsne')}
|
| 926 |
+
style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
|
| 927 |
+
title="UMAP preserves global structure, t-SNE emphasizes local clusters"
|
| 928 |
>
|
| 929 |
+
<option value="umap">UMAP</option>
|
| 930 |
+
<option value="tsne">t-SNE</option>
|
| 931 |
</select>
|
| 932 |
+
</div>
|
| 933 |
+
</details>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
</div>
|
| 935 |
|
| 936 |
+
{/* Display Options - Simplified */}
|
| 937 |
+
<details className="sidebar-section" open>
|
| 938 |
+
<summary style={{ cursor: 'pointer', fontWeight: '600', fontSize: '1rem', marginBottom: '1rem', listStyle: 'none' }}>
|
| 939 |
+
<h3 style={{ display: 'inline', margin: 0 }}>Display Options</h3>
|
| 940 |
+
</summary>
|
| 941 |
|
| 942 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
| 943 |
+
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} title="Show only root models without parents">
|
| 944 |
+
<input
|
| 945 |
+
type="checkbox"
|
| 946 |
+
checked={baseModelsOnly}
|
| 947 |
+
onChange={(e) => setBaseModelsOnly(e.target.checked)}
|
| 948 |
+
style={{ marginRight: '0.5rem', cursor: 'pointer' }}
|
| 949 |
+
/>
|
| 950 |
+
<span style={{ fontWeight: '500', fontSize: '0.9rem' }}>Base Models Only</span>
|
| 951 |
+
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
|
| 953 |
+
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} title="Use family tree structure in embeddings">
|
| 954 |
+
<input
|
| 955 |
+
type="checkbox"
|
| 956 |
+
checked={useGraphEmbeddings}
|
| 957 |
+
onChange={(e) => setUseGraphEmbeddings(e.target.checked)}
|
| 958 |
+
style={{ marginRight: '0.5rem', cursor: 'pointer' }}
|
| 959 |
+
/>
|
| 960 |
+
<span style={{ fontWeight: '500', fontSize: '0.9rem' }}>Graph-Aware Layout</span>
|
| 961 |
+
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
|
| 963 |
+
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} title="Sort by similarity to a specific model">
|
| 964 |
+
<input
|
| 965 |
+
type="checkbox"
|
| 966 |
+
checked={semanticSimilarityMode}
|
| 967 |
+
onChange={(e) => {
|
| 968 |
+
setSemanticSimilarityMode(e.target.checked);
|
| 969 |
+
if (!e.target.checked) {
|
| 970 |
+
setSemanticQueryModel(null);
|
| 971 |
+
}
|
| 972 |
+
}}
|
| 973 |
+
style={{ marginRight: '0.5rem', cursor: 'pointer' }}
|
| 974 |
+
/>
|
| 975 |
+
<span style={{ fontWeight: '500', fontSize: '0.9rem' }}>Similarity View</span>
|
| 976 |
+
</label>
|
| 977 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 978 |
|
| 979 |
{semanticSimilarityMode && (
|
| 980 |
+
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f9f9f9', borderRadius: '0', border: '1px solid #e0e0e0' }}>
|
|
|
|
|
|
|
|
|
|
| 981 |
<input
|
| 982 |
type="text"
|
| 983 |
value={semanticQueryModel || ''}
|
| 984 |
onChange={(e) => setSemanticQueryModel(e.target.value || null)}
|
| 985 |
+
placeholder="Enter model ID..."
|
| 986 |
+
style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
|
| 987 |
+
title="Enter a model ID to compare against"
|
| 988 |
/>
|
| 989 |
+
{selectedModel && (
|
| 990 |
+
<button
|
| 991 |
+
onClick={() => setSemanticQueryModel(selectedModel.model_id)}
|
| 992 |
+
style={{
|
| 993 |
+
marginTop: '0.5rem',
|
| 994 |
+
padding: '0.4rem 0.7rem',
|
| 995 |
+
background: '#4a90e2',
|
| 996 |
+
color: 'white',
|
| 997 |
+
border: 'none',
|
| 998 |
+
borderRadius: '4px',
|
| 999 |
+
cursor: 'pointer',
|
| 1000 |
+
fontSize: '0.8rem',
|
| 1001 |
+
width: '100%'
|
| 1002 |
+
}}
|
| 1003 |
+
title="Use the currently selected model"
|
| 1004 |
+
>
|
| 1005 |
+
Use Selected Model
|
| 1006 |
+
</button>
|
| 1007 |
+
)}
|
|
|
|
|
|
|
| 1008 |
</div>
|
| 1009 |
)}
|
| 1010 |
+
</details>
|
| 1011 |
|
| 1012 |
{/* Structural Visualization Options */}
|
| 1013 |
+
{viewMode === 'network' && (
|
| 1014 |
<div className="sidebar-section">
|
| 1015 |
<h3>Network Structure</h3>
|
| 1016 |
<div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '1rem', lineHeight: '1.4' }}>
|
|
|
|
| 1048 |
</label>
|
| 1049 |
|
| 1050 |
{showNetworkEdges && (
|
| 1051 |
+
<div style={{ marginLeft: '1.5rem', marginBottom: '1rem', padding: '0.75rem', background: 'white', borderRadius: '0', border: '1px solid #d0d0d0' }}>
|
| 1052 |
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>
|
| 1053 |
Connection Type
|
| 1054 |
</label>
|
| 1055 |
<select
|
| 1056 |
value={networkEdgeType}
|
| 1057 |
onChange={(e) => setNetworkEdgeType(e.target.value as 'library' | 'pipeline' | 'combined')}
|
| 1058 |
+
style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
|
| 1059 |
>
|
| 1060 |
<option value="combined">Combined (library + pipeline + tags)</option>
|
| 1061 |
<option value="library">Library Only</option>
|
|
|
|
| 1081 |
</div>
|
| 1082 |
)}
|
| 1083 |
|
| 1084 |
+
{/* Advanced Hierarchy Controls */}
|
| 1085 |
+
<details className="sidebar-section" style={{ border: '1px solid #e0e0e0', borderRadius: '0', padding: '0.75rem' }}>
|
| 1086 |
+
<summary style={{ cursor: 'pointer', fontWeight: '600', fontSize: '0.95rem', listStyle: 'none' }}>
|
| 1087 |
+
Hierarchy & Structure
|
| 1088 |
+
</summary>
|
| 1089 |
+
<div style={{ marginTop: '1rem' }}>
|
| 1090 |
+
<label style={{ marginBottom: '1rem', display: 'block' }}>
|
| 1091 |
+
<span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
|
| 1092 |
+
Max Hierarchy Depth
|
| 1093 |
+
</span>
|
| 1094 |
+
<input
|
| 1095 |
+
type="range"
|
| 1096 |
+
min="0"
|
| 1097 |
+
max="10"
|
| 1098 |
+
value={maxHierarchyDepth ?? 10}
|
| 1099 |
+
onChange={(e) => {
|
| 1100 |
+
const val = parseInt(e.target.value);
|
| 1101 |
+
setMaxHierarchyDepth(val === 10 ? null : val);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1102 |
}}
|
| 1103 |
+
style={{ width: '100%' }}
|
| 1104 |
+
/>
|
| 1105 |
+
<div style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.25rem', display: 'flex', justifyContent: 'space-between' }}>
|
| 1106 |
+
<span>All levels</span>
|
| 1107 |
+
<span>{maxHierarchyDepth !== null ? `Depth ≤ ${maxHierarchyDepth}` : 'No limit'}</span>
|
| 1108 |
+
</div>
|
| 1109 |
+
</label>
|
| 1110 |
+
<label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 1111 |
+
<input
|
| 1112 |
+
type="checkbox"
|
| 1113 |
+
checked={showDistanceHeatmap}
|
| 1114 |
+
onChange={(e) => setShowDistanceHeatmap(e.target.checked)}
|
| 1115 |
+
/>
|
| 1116 |
+
<span style={{ fontSize: '0.85rem' }}>Distance Heatmap</span>
|
| 1117 |
+
</label>
|
| 1118 |
+
{selectedModel && (
|
| 1119 |
+
<div style={{ marginTop: '0.5rem', padding: '0.5rem', background: '#f5f5f5', borderRadius: '0', fontSize: '0.85rem' }}>
|
| 1120 |
+
<div style={{ fontWeight: '500', marginBottom: '0.25rem' }}>Selected Model:</div>
|
| 1121 |
+
<div style={{ color: '#666', marginBottom: '0.5rem', wordBreak: 'break-word' }}>{selectedModel.model_id}</div>
|
| 1122 |
+
{selectedModel.family_depth !== null && (
|
| 1123 |
+
<div style={{ color: '#666', marginBottom: '0.5rem' }}>
|
| 1124 |
+
Hierarchy Depth: {selectedModel.family_depth}
|
| 1125 |
+
</div>
|
| 1126 |
+
)}
|
| 1127 |
+
<button
|
| 1128 |
+
onClick={() => {
|
| 1129 |
+
if (selectedModel.parent_model) {
|
| 1130 |
+
loadFamilyPath(selectedModel.model_id, selectedModel.parent_model);
|
| 1131 |
+
} else {
|
| 1132 |
+
loadFamilyPath(selectedModel.model_id);
|
| 1133 |
+
}
|
| 1134 |
+
}}
|
| 1135 |
+
style={{
|
| 1136 |
+
padding: '0.25rem 0.5rem',
|
| 1137 |
+
fontSize: '0.8rem',
|
| 1138 |
+
background: '#4a90e2',
|
| 1139 |
+
color: 'white',
|
| 1140 |
+
border: 'none',
|
| 1141 |
+
borderRadius: '0',
|
| 1142 |
+
cursor: 'pointer',
|
| 1143 |
+
marginRight: '0.5rem',
|
| 1144 |
+
marginBottom: '0.5rem'
|
| 1145 |
+
}}
|
| 1146 |
+
>
|
| 1147 |
+
Show Path to Root
|
| 1148 |
+
</button>
|
| 1149 |
+
<button
|
| 1150 |
+
onClick={() => setHighlightedPath([])}
|
| 1151 |
+
style={{
|
| 1152 |
+
padding: '0.25rem 0.5rem',
|
| 1153 |
+
fontSize: '0.8rem',
|
| 1154 |
+
background: '#6a6a6a',
|
| 1155 |
+
color: 'white',
|
| 1156 |
+
border: 'none',
|
| 1157 |
+
borderRadius: '0',
|
| 1158 |
+
cursor: 'pointer',
|
| 1159 |
+
marginBottom: '0.5rem'
|
| 1160 |
+
}}
|
| 1161 |
+
>
|
| 1162 |
+
Clear Path
|
| 1163 |
+
</button>
|
| 1164 |
+
</div>
|
| 1165 |
+
)}
|
| 1166 |
+
</div>
|
| 1167 |
+
</details>
|
| 1168 |
|
| 1169 |
<div className="sidebar-section">
|
| 1170 |
<h3>Family Tree Explorer</h3>
|
|
|
|
| 1175 |
onChange={(e) => setSearchInput(e.target.value)}
|
| 1176 |
onFocus={() => searchInput.length > 0 && setShowSearchResults(true)}
|
| 1177 |
placeholder="Type model name..."
|
| 1178 |
+
style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0' }}
|
| 1179 |
/>
|
| 1180 |
{showSearchResults && searchResults.length > 0 && (
|
| 1181 |
<div style={{
|
|
|
|
| 1187 |
border: '1px solid #d0d0d0',
|
| 1188 |
borderRadius: '2px',
|
| 1189 |
marginTop: '2px',
|
| 1190 |
+
maxHeight: '400px',
|
|
|
|
| 1191 |
zIndex: 1000,
|
| 1192 |
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
| 1193 |
}}>
|
| 1194 |
+
<VirtualSearchResults
|
| 1195 |
+
results={searchResults}
|
| 1196 |
+
onSelect={(result) => {
|
| 1197 |
+
loadFamilyTree(result.model_id);
|
| 1198 |
+
}}
|
| 1199 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
</div>
|
| 1201 |
)}
|
| 1202 |
</div>
|
|
|
|
| 1228 |
|
| 1229 |
{/* Bookmarks */}
|
| 1230 |
{bookmarkedModels.length > 0 && (
|
| 1231 |
+
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '0', border: '1px solid #d0d0d0' }}>
|
| 1232 |
<h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Bookmarks ({bookmarkedModels.length})</h3>
|
| 1233 |
<div style={{ maxHeight: '150px', overflowY: 'auto', fontSize: '0.85rem' }}>
|
| 1234 |
{bookmarkedModels.map(modelId => (
|
|
|
|
| 1254 |
|
| 1255 |
{/* Comparison */}
|
| 1256 |
{comparisonModels.length > 0 && (
|
| 1257 |
+
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '0', border: '1px solid #d0d0d0' }}>
|
| 1258 |
<h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Comparison ({comparisonModels.length}/3)</h3>
|
| 1259 |
{comparisonModels.map(model => (
|
| 1260 |
+
<div key={model.model_id} style={{ marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '0', fontSize: '0.85rem' }}>
|
| 1261 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 1262 |
<strong>{model.model_id}</strong>
|
| 1263 |
<button
|
|
|
|
| 1284 |
|
| 1285 |
{/* Similar Models */}
|
| 1286 |
{showSimilar && similarModels.length > 0 && (
|
| 1287 |
+
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '0', border: '1px solid #d0d0d0' }}>
|
| 1288 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
| 1289 |
<h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Similar Models</h3>
|
| 1290 |
<button
|
|
|
|
| 1296 |
</div>
|
| 1297 |
<div style={{ maxHeight: '200px', overflowY: 'auto', fontSize: '0.85rem' }}>
|
| 1298 |
{similarModels.map((similar, idx) => (
|
| 1299 |
+
<div key={idx} style={{ marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '0' }}>
|
| 1300 |
<div style={{ fontWeight: '500' }}>{similar.model_id}</div>
|
| 1301 |
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
|
| 1302 |
Similarity: {(similar.similarity * 100).toFixed(1)}% | Distance: {similar.distance.toFixed(3)}
|
|
|
|
| 1313 |
|
| 1314 |
|
| 1315 |
{selectedModels.length > 0 && (
|
| 1316 |
+
<div style={{ marginTop: '1rem', padding: '0.5rem', background: '#e3f2fd', borderRadius: '0' }}>
|
| 1317 |
<strong>Selected: {selectedModels.length} models</strong>
|
| 1318 |
<button
|
| 1319 |
onClick={() => setSelectedModels([])}
|
|
|
|
| 1333 |
)}
|
| 1334 |
{!loading && !error && data.length > 0 && (
|
| 1335 |
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1336 |
{viewMode === 'scatter' && (
|
| 1337 |
+
<ScatterPlot
|
| 1338 |
width={width}
|
| 1339 |
height={height}
|
| 1340 |
data={data}
|
|
|
|
| 1349 |
}}
|
| 1350 |
/>
|
| 1351 |
)}
|
| 1352 |
+
{viewMode === '3d' && (
|
| 1353 |
+
<ScatterPlot3D
|
| 1354 |
+
data={data}
|
| 1355 |
+
colorBy={colorBy}
|
| 1356 |
+
sizeBy={sizeBy}
|
| 1357 |
+
hoveredModel={hoveredModel}
|
| 1358 |
+
onPointClick={(model) => {
|
| 1359 |
+
setSelectedModel(model);
|
| 1360 |
+
setIsModalOpen(true);
|
| 1361 |
+
}}
|
| 1362 |
+
onHover={(model, position) => {
|
| 1363 |
+
setHoveredModel(model);
|
| 1364 |
+
if (model && position) {
|
| 1365 |
+
setTooltipPosition(position);
|
| 1366 |
+
} else {
|
| 1367 |
+
setTooltipPosition(null);
|
| 1368 |
+
}
|
| 1369 |
+
}}
|
| 1370 |
+
/>
|
| 1371 |
+
)}
|
| 1372 |
{viewMode === 'network' && (
|
| 1373 |
<NetworkGraph
|
| 1374 |
width={width}
|
|
|
|
| 1383 |
{viewMode === 'distribution' && (
|
| 1384 |
<DistributionView data={data} width={width} height={height} />
|
| 1385 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1386 |
</>
|
| 1387 |
)}
|
| 1388 |
</main>
|
frontend/src/components/controls/ClusterFilter.css
CHANGED
|
@@ -21,7 +21,7 @@
|
|
| 21 |
width: 100%;
|
| 22 |
padding: 0.5rem;
|
| 23 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 24 |
-
border-radius:
|
| 25 |
font-size: 0.9rem;
|
| 26 |
background: var(--bg-primary, #ffffff);
|
| 27 |
color: var(--text-primary, #1a1a1a);
|
|
@@ -42,7 +42,7 @@
|
|
| 42 |
flex: 1;
|
| 43 |
padding: 0.4rem 0.6rem;
|
| 44 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 45 |
-
border-radius:
|
| 46 |
background: var(--bg-primary, #ffffff);
|
| 47 |
color: var(--text-primary, #1a1a1a);
|
| 48 |
font-size: 0.85rem;
|
|
@@ -64,7 +64,7 @@
|
|
| 64 |
max-height: 300px;
|
| 65 |
overflow-y: auto;
|
| 66 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 67 |
-
border-radius:
|
| 68 |
padding: 0.5rem;
|
| 69 |
background: var(--bg-primary, #ffffff);
|
| 70 |
}
|
|
@@ -75,7 +75,7 @@
|
|
| 75 |
gap: 0.5rem;
|
| 76 |
padding: 0.5rem;
|
| 77 |
cursor: pointer;
|
| 78 |
-
border-radius:
|
| 79 |
transition: background 0.15s;
|
| 80 |
font-size: 0.85rem;
|
| 81 |
}
|
|
@@ -96,7 +96,7 @@
|
|
| 96 |
.cluster-color-indicator {
|
| 97 |
width: 12px;
|
| 98 |
height: 12px;
|
| 99 |
-
border-radius:
|
| 100 |
flex-shrink: 0;
|
| 101 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 102 |
}
|
|
|
|
| 21 |
width: 100%;
|
| 22 |
padding: 0.5rem;
|
| 23 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 24 |
+
border-radius: 0;
|
| 25 |
font-size: 0.9rem;
|
| 26 |
background: var(--bg-primary, #ffffff);
|
| 27 |
color: var(--text-primary, #1a1a1a);
|
|
|
|
| 42 |
flex: 1;
|
| 43 |
padding: 0.4rem 0.6rem;
|
| 44 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 45 |
+
border-radius: 0;
|
| 46 |
background: var(--bg-primary, #ffffff);
|
| 47 |
color: var(--text-primary, #1a1a1a);
|
| 48 |
font-size: 0.85rem;
|
|
|
|
| 64 |
max-height: 300px;
|
| 65 |
overflow-y: auto;
|
| 66 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 67 |
+
border-radius: 0;
|
| 68 |
padding: 0.5rem;
|
| 69 |
background: var(--bg-primary, #ffffff);
|
| 70 |
}
|
|
|
|
| 75 |
gap: 0.5rem;
|
| 76 |
padding: 0.5rem;
|
| 77 |
cursor: pointer;
|
| 78 |
+
border-radius: 0;
|
| 79 |
transition: background 0.15s;
|
| 80 |
font-size: 0.85rem;
|
| 81 |
}
|
|
|
|
| 96 |
.cluster-color-indicator {
|
| 97 |
width: 12px;
|
| 98 |
height: 12px;
|
| 99 |
+
border-radius: 0;
|
| 100 |
flex-shrink: 0;
|
| 101 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 102 |
}
|
frontend/src/components/controls/RenderingStyleSelector.css
CHANGED
|
@@ -17,7 +17,7 @@
|
|
| 17 |
width: 100%;
|
| 18 |
padding: 0.5rem;
|
| 19 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 20 |
-
border-radius:
|
| 21 |
background: var(--bg-primary, #ffffff);
|
| 22 |
color: var(--text-primary, #1a1a1a);
|
| 23 |
font-size: 0.9rem;
|
|
|
|
| 17 |
width: 100%;
|
| 18 |
padding: 0.5rem;
|
| 19 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 20 |
+
border-radius: 0;
|
| 21 |
background: var(--bg-primary, #ffffff);
|
| 22 |
color: var(--text-primary, #1a1a1a);
|
| 23 |
font-size: 0.9rem;
|
frontend/src/components/controls/ThemeToggle.tsx
CHANGED
|
@@ -15,7 +15,7 @@ export default function ThemeToggle() {
|
|
| 15 |
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
| 16 |
aria-label={`Current theme: ${theme}. Click to switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
| 17 |
>
|
| 18 |
-
{theme === 'light' ? '
|
| 19 |
</button>
|
| 20 |
);
|
| 21 |
}
|
|
|
|
| 15 |
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
| 16 |
aria-label={`Current theme: ${theme}. Click to switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
| 17 |
>
|
| 18 |
+
{theme === 'light' ? 'Dark' : 'Light'}
|
| 19 |
</button>
|
| 20 |
);
|
| 21 |
}
|
frontend/src/components/controls/VisualizationModeButtons.css
CHANGED
|
@@ -21,7 +21,7 @@
|
|
| 21 |
gap: 0.5rem;
|
| 22 |
padding: 0.5rem 1rem;
|
| 23 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 24 |
-
border-radius:
|
| 25 |
background: var(--bg-primary, #ffffff);
|
| 26 |
color: var(--text-primary, #1a1a1a);
|
| 27 |
font-size: 0.9rem;
|
|
|
|
| 21 |
gap: 0.5rem;
|
| 22 |
padding: 0.5rem 1rem;
|
| 23 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 24 |
+
border-radius: 0;
|
| 25 |
background: var(--bg-primary, #ffffff);
|
| 26 |
color: var(--text-primary, #1a1a1a);
|
| 27 |
font-size: 0.9rem;
|
frontend/src/components/controls/VisualizationModeButtons.tsx
CHANGED
|
@@ -14,12 +14,9 @@ interface ModeOption {
|
|
| 14 |
}
|
| 15 |
|
| 16 |
const MODES: ModeOption[] = [
|
| 17 |
-
{ value: '
|
| 18 |
-
{ value: '
|
| 19 |
-
{ value: '
|
| 20 |
-
{ value: 'distribution', label: 'Distribution', icon: '📈', description: 'Statistical distributions' },
|
| 21 |
-
{ value: 'stacked', label: 'Stacked', icon: '📚', description: 'Hierarchical view' },
|
| 22 |
-
{ value: 'heatmap', label: 'Heatmap', icon: '🔥', description: 'Density heatmap' },
|
| 23 |
];
|
| 24 |
|
| 25 |
export default function VisualizationModeButtons() {
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
const MODES: ModeOption[] = [
|
| 17 |
+
{ value: 'scatter', label: '2D Scatter', icon: '', description: '2D projection view' },
|
| 18 |
+
{ value: 'network', label: 'Network', icon: '', description: 'Network graph view' },
|
| 19 |
+
{ value: 'distribution', label: 'Distribution', icon: '', description: 'Statistical distributions' },
|
|
|
|
|
|
|
|
|
|
| 20 |
];
|
| 21 |
|
| 22 |
export default function VisualizationModeButtons() {
|
frontend/src/components/layout/SearchBar.css
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
align-items: center;
|
| 12 |
background: white;
|
| 13 |
border: 2px solid #e0e0e0;
|
| 14 |
-
border-radius:
|
| 15 |
padding: 8px 12px;
|
| 16 |
transition: border-color 0.2s;
|
| 17 |
}
|
|
@@ -26,7 +26,7 @@
|
|
| 26 |
border: none;
|
| 27 |
outline: none;
|
| 28 |
font-size: 14px;
|
| 29 |
-
font-family: '
|
| 30 |
color: #333;
|
| 31 |
background: transparent;
|
| 32 |
}
|
|
@@ -71,7 +71,7 @@
|
|
| 71 |
margin-top: 4px;
|
| 72 |
background: white;
|
| 73 |
border: 1px solid #e0e0e0;
|
| 74 |
-
border-radius:
|
| 75 |
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 76 |
max-height: 400px;
|
| 77 |
overflow-y: auto;
|
|
@@ -105,7 +105,7 @@
|
|
| 105 |
font-size: 14px;
|
| 106 |
font-weight: 600;
|
| 107 |
color: #333;
|
| 108 |
-
font-family: '
|
| 109 |
}
|
| 110 |
|
| 111 |
.result-org {
|
|
@@ -113,7 +113,7 @@
|
|
| 113 |
color: #666;
|
| 114 |
background: #f0f0f0;
|
| 115 |
padding: 2px 6px;
|
| 116 |
-
border-radius:
|
| 117 |
}
|
| 118 |
|
| 119 |
.result-meta {
|
|
@@ -128,7 +128,7 @@
|
|
| 128 |
color: #666;
|
| 129 |
background: #e8e8e8;
|
| 130 |
padding: 2px 6px;
|
| 131 |
-
border-radius:
|
| 132 |
}
|
| 133 |
|
| 134 |
.result-snippet {
|
|
@@ -141,7 +141,7 @@
|
|
| 141 |
.result-snippet mark {
|
| 142 |
background: #fff3cd;
|
| 143 |
padding: 1px 2px;
|
| 144 |
-
border-radius:
|
| 145 |
}
|
| 146 |
|
| 147 |
.search-no-results {
|
|
@@ -167,12 +167,12 @@
|
|
| 167 |
|
| 168 |
.search-results::-webkit-scrollbar-track {
|
| 169 |
background: #f1f1f1;
|
| 170 |
-
border-radius:
|
| 171 |
}
|
| 172 |
|
| 173 |
.search-results::-webkit-scrollbar-thumb {
|
| 174 |
background: #c1c1c1;
|
| 175 |
-
border-radius:
|
| 176 |
}
|
| 177 |
|
| 178 |
.search-results::-webkit-scrollbar-thumb:hover {
|
|
|
|
| 11 |
align-items: center;
|
| 12 |
background: white;
|
| 13 |
border: 2px solid #e0e0e0;
|
| 14 |
+
border-radius: 0;
|
| 15 |
padding: 8px 12px;
|
| 16 |
transition: border-color 0.2s;
|
| 17 |
}
|
|
|
|
| 26 |
border: none;
|
| 27 |
outline: none;
|
| 28 |
font-size: 14px;
|
| 29 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 30 |
color: #333;
|
| 31 |
background: transparent;
|
| 32 |
}
|
|
|
|
| 71 |
margin-top: 4px;
|
| 72 |
background: white;
|
| 73 |
border: 1px solid #e0e0e0;
|
| 74 |
+
border-radius: 0;
|
| 75 |
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 76 |
max-height: 400px;
|
| 77 |
overflow-y: auto;
|
|
|
|
| 105 |
font-size: 14px;
|
| 106 |
font-weight: 600;
|
| 107 |
color: #333;
|
| 108 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 109 |
}
|
| 110 |
|
| 111 |
.result-org {
|
|
|
|
| 113 |
color: #666;
|
| 114 |
background: #f0f0f0;
|
| 115 |
padding: 2px 6px;
|
| 116 |
+
border-radius: 0;
|
| 117 |
}
|
| 118 |
|
| 119 |
.result-meta {
|
|
|
|
| 128 |
color: #666;
|
| 129 |
background: #e8e8e8;
|
| 130 |
padding: 2px 6px;
|
| 131 |
+
border-radius: 0;
|
| 132 |
}
|
| 133 |
|
| 134 |
.result-snippet {
|
|
|
|
| 141 |
.result-snippet mark {
|
| 142 |
background: #fff3cd;
|
| 143 |
padding: 1px 2px;
|
| 144 |
+
border-radius: 0;
|
| 145 |
}
|
| 146 |
|
| 147 |
.search-no-results {
|
|
|
|
| 167 |
|
| 168 |
.search-results::-webkit-scrollbar-track {
|
| 169 |
background: #f1f1f1;
|
| 170 |
+
border-radius: 0;
|
| 171 |
}
|
| 172 |
|
| 173 |
.search-results::-webkit-scrollbar-thumb {
|
| 174 |
background: #c1c1c1;
|
| 175 |
+
border-radius: 0;
|
| 176 |
}
|
| 177 |
|
| 178 |
.search-results::-webkit-scrollbar-thumb:hover {
|
frontend/src/components/modals/FileTree.css
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
.file-tree-container {
|
| 2 |
margin-top: 1rem;
|
| 3 |
border: 1px solid #e0e0e0;
|
| 4 |
-
border-radius:
|
| 5 |
background: #fafafa;
|
| 6 |
max-height: 600px;
|
| 7 |
overflow-y: auto;
|
|
@@ -29,7 +29,7 @@
|
|
| 29 |
background: #e3f2fd;
|
| 30 |
color: #1976d2;
|
| 31 |
padding: 0.2rem 0.5rem;
|
| 32 |
-
border-radius:
|
| 33 |
font-size: 0.75rem;
|
| 34 |
font-weight: 500;
|
| 35 |
}
|
|
@@ -49,12 +49,12 @@
|
|
| 49 |
.file-tree-button {
|
| 50 |
background: #f0f0f0;
|
| 51 |
border: 1px solid #d0d0d0;
|
| 52 |
-
border-radius:
|
| 53 |
padding: 0.25rem 0.5rem;
|
| 54 |
font-size: 0.75rem;
|
| 55 |
cursor: pointer;
|
| 56 |
color: #333;
|
| 57 |
-
font-family: '
|
| 58 |
transition: background 0.15s;
|
| 59 |
}
|
| 60 |
|
|
@@ -89,9 +89,9 @@
|
|
| 89 |
width: 100%;
|
| 90 |
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
| 91 |
border: 1px solid #d0d0d0;
|
| 92 |
-
border-radius:
|
| 93 |
font-size: 0.85rem;
|
| 94 |
-
font-family: '
|
| 95 |
}
|
| 96 |
|
| 97 |
.file-tree-search-input:focus {
|
|
@@ -112,7 +112,7 @@
|
|
| 112 |
display: flex;
|
| 113 |
align-items: center;
|
| 114 |
justify-content: center;
|
| 115 |
-
border-radius:
|
| 116 |
}
|
| 117 |
|
| 118 |
.file-tree-clear:hover {
|
|
@@ -123,9 +123,9 @@
|
|
| 123 |
.file-tree-type-filter {
|
| 124 |
padding: 0.5rem 0.75rem;
|
| 125 |
border: 1px solid #d0d0d0;
|
| 126 |
-
border-radius:
|
| 127 |
font-size: 0.85rem;
|
| 128 |
-
font-family: '
|
| 129 |
background: white;
|
| 130 |
cursor: pointer;
|
| 131 |
min-width: 150px;
|
|
@@ -154,7 +154,7 @@
|
|
| 154 |
align-items: center;
|
| 155 |
gap: 0.5rem;
|
| 156 |
padding: 0.375rem 0.5rem;
|
| 157 |
-
border-radius:
|
| 158 |
transition: background 0.15s;
|
| 159 |
user-select: none;
|
| 160 |
}
|
|
@@ -189,7 +189,7 @@
|
|
| 189 |
cursor: pointer;
|
| 190 |
font-size: 0.9rem;
|
| 191 |
padding: 0.25rem;
|
| 192 |
-
border-radius:
|
| 193 |
display: flex;
|
| 194 |
align-items: center;
|
| 195 |
justify-content: center;
|
|
@@ -254,12 +254,12 @@
|
|
| 254 |
|
| 255 |
.file-tree-container::-webkit-scrollbar-track {
|
| 256 |
background: #f1f1f1;
|
| 257 |
-
border-radius:
|
| 258 |
}
|
| 259 |
|
| 260 |
.file-tree-container::-webkit-scrollbar-thumb {
|
| 261 |
background: #c1c1c1;
|
| 262 |
-
border-radius:
|
| 263 |
}
|
| 264 |
|
| 265 |
.file-tree-container::-webkit-scrollbar-thumb:hover {
|
|
|
|
| 1 |
.file-tree-container {
|
| 2 |
margin-top: 1rem;
|
| 3 |
border: 1px solid #e0e0e0;
|
| 4 |
+
border-radius: 0;
|
| 5 |
background: #fafafa;
|
| 6 |
max-height: 600px;
|
| 7 |
overflow-y: auto;
|
|
|
|
| 29 |
background: #e3f2fd;
|
| 30 |
color: #1976d2;
|
| 31 |
padding: 0.2rem 0.5rem;
|
| 32 |
+
border-radius: 0;
|
| 33 |
font-size: 0.75rem;
|
| 34 |
font-weight: 500;
|
| 35 |
}
|
|
|
|
| 49 |
.file-tree-button {
|
| 50 |
background: #f0f0f0;
|
| 51 |
border: 1px solid #d0d0d0;
|
| 52 |
+
border-radius: 0;
|
| 53 |
padding: 0.25rem 0.5rem;
|
| 54 |
font-size: 0.75rem;
|
| 55 |
cursor: pointer;
|
| 56 |
color: #333;
|
| 57 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 58 |
transition: background 0.15s;
|
| 59 |
}
|
| 60 |
|
|
|
|
| 89 |
width: 100%;
|
| 90 |
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
| 91 |
border: 1px solid #d0d0d0;
|
| 92 |
+
border-radius: 0;
|
| 93 |
font-size: 0.85rem;
|
| 94 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 95 |
}
|
| 96 |
|
| 97 |
.file-tree-search-input:focus {
|
|
|
|
| 112 |
display: flex;
|
| 113 |
align-items: center;
|
| 114 |
justify-content: center;
|
| 115 |
+
border-radius: 0;
|
| 116 |
}
|
| 117 |
|
| 118 |
.file-tree-clear:hover {
|
|
|
|
| 123 |
.file-tree-type-filter {
|
| 124 |
padding: 0.5rem 0.75rem;
|
| 125 |
border: 1px solid #d0d0d0;
|
| 126 |
+
border-radius: 0;
|
| 127 |
font-size: 0.85rem;
|
| 128 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 129 |
background: white;
|
| 130 |
cursor: pointer;
|
| 131 |
min-width: 150px;
|
|
|
|
| 154 |
align-items: center;
|
| 155 |
gap: 0.5rem;
|
| 156 |
padding: 0.375rem 0.5rem;
|
| 157 |
+
border-radius: 0;
|
| 158 |
transition: background 0.15s;
|
| 159 |
user-select: none;
|
| 160 |
}
|
|
|
|
| 189 |
cursor: pointer;
|
| 190 |
font-size: 0.9rem;
|
| 191 |
padding: 0.25rem;
|
| 192 |
+
border-radius: 0;
|
| 193 |
display: flex;
|
| 194 |
align-items: center;
|
| 195 |
justify-content: center;
|
|
|
|
| 254 |
|
| 255 |
.file-tree-container::-webkit-scrollbar-track {
|
| 256 |
background: #f1f1f1;
|
| 257 |
+
border-radius: 0;
|
| 258 |
}
|
| 259 |
|
| 260 |
.file-tree-container::-webkit-scrollbar-thumb {
|
| 261 |
background: #c1c1c1;
|
| 262 |
+
border-radius: 0;
|
| 263 |
}
|
| 264 |
|
| 265 |
.file-tree-container::-webkit-scrollbar-thumb:hover {
|
frontend/src/components/modals/FileTree.tsx
CHANGED
|
@@ -281,24 +281,24 @@ export default function FileTree({ modelId }: FileTreeProps) {
|
|
| 281 |
|
| 282 |
const getFileIcon = (node: FileNode): string => {
|
| 283 |
if (node.type === 'directory') {
|
| 284 |
-
return expandedPaths.has(node.path) ? '
|
| 285 |
}
|
| 286 |
const ext = node.path.split('.').pop()?.toLowerCase();
|
| 287 |
const iconMap: Record<string, string> = {
|
| 288 |
-
'py': '
|
| 289 |
-
'json': '
|
| 290 |
-
'txt': '
|
| 291 |
-
'md': '
|
| 292 |
-
'yml': '
|
| 293 |
-
'yaml': '
|
| 294 |
-
'bin': '
|
| 295 |
-
'safetensors': '
|
| 296 |
-
'pt': '
|
| 297 |
-
'pth': '
|
| 298 |
-
'onnx': '
|
| 299 |
-
'pb': '
|
| 300 |
};
|
| 301 |
-
return iconMap[ext || ''] || '
|
| 302 |
};
|
| 303 |
|
| 304 |
const copyFilePath = (path: string) => {
|
|
@@ -348,7 +348,7 @@ export default function FileTree({ modelId }: FileTreeProps) {
|
|
| 348 |
title="Copy file path"
|
| 349 |
aria-label="Copy path"
|
| 350 |
>
|
| 351 |
-
|
| 352 |
</button>
|
| 353 |
<a
|
| 354 |
href={getFileUrl(node.path)}
|
|
@@ -359,7 +359,7 @@ export default function FileTree({ modelId }: FileTreeProps) {
|
|
| 359 |
aria-label="Download"
|
| 360 |
onClick={(e) => e.stopPropagation()}
|
| 361 |
>
|
| 362 |
-
|
| 363 |
</a>
|
| 364 |
</div>
|
| 365 |
)}
|
|
|
|
| 281 |
|
| 282 |
const getFileIcon = (node: FileNode): string => {
|
| 283 |
if (node.type === 'directory') {
|
| 284 |
+
return expandedPaths.has(node.path) ? '▼' : '▶';
|
| 285 |
}
|
| 286 |
const ext = node.path.split('.').pop()?.toLowerCase();
|
| 287 |
const iconMap: Record<string, string> = {
|
| 288 |
+
'py': 'py',
|
| 289 |
+
'json': 'json',
|
| 290 |
+
'txt': 'txt',
|
| 291 |
+
'md': 'md',
|
| 292 |
+
'yml': 'yml',
|
| 293 |
+
'yaml': 'yaml',
|
| 294 |
+
'bin': 'bin',
|
| 295 |
+
'safetensors': 'st',
|
| 296 |
+
'pt': 'pt',
|
| 297 |
+
'pth': 'pth',
|
| 298 |
+
'onnx': 'onnx',
|
| 299 |
+
'pb': 'pb',
|
| 300 |
};
|
| 301 |
+
return iconMap[ext || ''] || '•';
|
| 302 |
};
|
| 303 |
|
| 304 |
const copyFilePath = (path: string) => {
|
|
|
|
| 348 |
title="Copy file path"
|
| 349 |
aria-label="Copy path"
|
| 350 |
>
|
| 351 |
+
Copy
|
| 352 |
</button>
|
| 353 |
<a
|
| 354 |
href={getFileUrl(node.path)}
|
|
|
|
| 359 |
aria-label="Download"
|
| 360 |
onClick={(e) => e.stopPropagation()}
|
| 361 |
>
|
| 362 |
+
Download
|
| 363 |
</a>
|
| 364 |
</div>
|
| 365 |
)}
|
frontend/src/components/modals/ModelModal.css
CHANGED
|
@@ -24,7 +24,7 @@
|
|
| 24 |
|
| 25 |
.modal-content {
|
| 26 |
background: #ffffff;
|
| 27 |
-
border-radius:
|
| 28 |
max-width: 900px;
|
| 29 |
width: 100%;
|
| 30 |
max-height: 90vh;
|
|
@@ -34,7 +34,7 @@
|
|
| 34 |
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
| 35 |
border: 1px solid #d0d0d0;
|
| 36 |
animation: slideUp 0.3s ease-out;
|
| 37 |
-
font-family: '
|
| 38 |
display: flex;
|
| 39 |
flex-direction: column;
|
| 40 |
}
|
|
@@ -69,9 +69,9 @@
|
|
| 69 |
display: flex;
|
| 70 |
align-items: center;
|
| 71 |
justify-content: center;
|
| 72 |
-
border-radius:
|
| 73 |
transition: all 0.2s;
|
| 74 |
-
font-family: '
|
| 75 |
}
|
| 76 |
|
| 77 |
.modal-close:hover {
|
|
@@ -94,7 +94,7 @@
|
|
| 94 |
font-size: 1.5rem;
|
| 95 |
color: #1a1a1a;
|
| 96 |
word-break: break-word;
|
| 97 |
-
font-family: '
|
| 98 |
font-weight: 600;
|
| 99 |
line-height: 1.3;
|
| 100 |
}
|
|
@@ -119,10 +119,10 @@
|
|
| 119 |
background: #6a6a6a;
|
| 120 |
color: white;
|
| 121 |
border: none;
|
| 122 |
-
border-radius:
|
| 123 |
cursor: pointer;
|
| 124 |
font-size: 0.85rem;
|
| 125 |
-
font-family: '
|
| 126 |
transition: all 0.2s;
|
| 127 |
font-weight: 500;
|
| 128 |
}
|
|
@@ -156,7 +156,7 @@
|
|
| 156 |
border-bottom: 2px solid transparent;
|
| 157 |
cursor: pointer;
|
| 158 |
font-size: 0.9rem;
|
| 159 |
-
font-family: '
|
| 160 |
color: #666;
|
| 161 |
font-weight: 500;
|
| 162 |
margin-bottom: -2px;
|
|
@@ -176,7 +176,7 @@
|
|
| 176 |
color: white;
|
| 177 |
font-size: 0.7rem;
|
| 178 |
padding: 0.15rem 0.4rem;
|
| 179 |
-
border-radius:
|
| 180 |
font-weight: 600;
|
| 181 |
margin-left: 0.25rem;
|
| 182 |
}
|
|
@@ -215,14 +215,14 @@
|
|
| 215 |
text-transform: uppercase;
|
| 216 |
letter-spacing: 0.5px;
|
| 217 |
font-weight: 600;
|
| 218 |
-
font-family: '
|
| 219 |
}
|
| 220 |
|
| 221 |
.info-value {
|
| 222 |
font-size: 1.1rem;
|
| 223 |
color: #1a1a1a;
|
| 224 |
font-weight: 500;
|
| 225 |
-
font-family: '
|
| 226 |
}
|
| 227 |
|
| 228 |
.info-value.highlight {
|
|
@@ -248,7 +248,7 @@
|
|
| 248 |
margin-bottom: 1.5rem;
|
| 249 |
padding: 1rem;
|
| 250 |
background: #fafafa;
|
| 251 |
-
border-radius:
|
| 252 |
border: 1px solid #e0e0e0;
|
| 253 |
}
|
| 254 |
|
|
@@ -259,7 +259,7 @@
|
|
| 259 |
letter-spacing: 0.5px;
|
| 260 |
font-weight: 600;
|
| 261 |
margin-bottom: 0.75rem;
|
| 262 |
-
font-family: '
|
| 263 |
}
|
| 264 |
|
| 265 |
.section-content {
|
|
@@ -279,7 +279,7 @@
|
|
| 279 |
padding: 0.35rem 0.75rem;
|
| 280 |
background: #e8f4f8;
|
| 281 |
color: #1a1a1a;
|
| 282 |
-
border-radius:
|
| 283 |
font-size: 0.85rem;
|
| 284 |
font-weight: 500;
|
| 285 |
border: 1px solid #c8e6c9;
|
|
@@ -316,7 +316,7 @@
|
|
| 316 |
color: #4a4a4a;
|
| 317 |
text-transform: uppercase;
|
| 318 |
letter-spacing: 0.5px;
|
| 319 |
-
font-family: '
|
| 320 |
}
|
| 321 |
|
| 322 |
.modal-info-grid {
|
|
@@ -335,25 +335,25 @@
|
|
| 335 |
font-size: 0.875rem;
|
| 336 |
color: #6a6a6a;
|
| 337 |
font-weight: 500;
|
| 338 |
-
font-family: '
|
| 339 |
}
|
| 340 |
|
| 341 |
.modal-info-item span {
|
| 342 |
font-size: 1rem;
|
| 343 |
color: #1a1a1a;
|
| 344 |
font-weight: 500;
|
| 345 |
-
font-family: '
|
| 346 |
}
|
| 347 |
|
| 348 |
.modal-tags {
|
| 349 |
margin: 0;
|
| 350 |
padding: 0.75rem;
|
| 351 |
background: #f5f5f5;
|
| 352 |
-
border-radius:
|
| 353 |
color: #1a1a1a;
|
| 354 |
font-size: 0.9rem;
|
| 355 |
line-height: 1.5;
|
| 356 |
-
font-family: '
|
| 357 |
}
|
| 358 |
|
| 359 |
.modal-footer {
|
|
@@ -372,9 +372,9 @@
|
|
| 372 |
background: #1a1a1a;
|
| 373 |
color: #ffffff;
|
| 374 |
text-decoration: none;
|
| 375 |
-
border-radius:
|
| 376 |
font-weight: 500;
|
| 377 |
-
font-family: '
|
| 378 |
transition: all 0.2s;
|
| 379 |
border: 1px solid #1a1a1a;
|
| 380 |
}
|
|
@@ -420,7 +420,7 @@
|
|
| 420 |
.papers-error {
|
| 421 |
color: #d32f2f;
|
| 422 |
background: #ffebee;
|
| 423 |
-
border-radius:
|
| 424 |
padding: 1rem;
|
| 425 |
}
|
| 426 |
|
|
@@ -433,7 +433,7 @@
|
|
| 433 |
.paper-card {
|
| 434 |
background: #f9f9f9;
|
| 435 |
border: 1px solid #e0e0e0;
|
| 436 |
-
border-radius:
|
| 437 |
padding: 1.5rem;
|
| 438 |
transition: box-shadow 0.2s;
|
| 439 |
}
|
|
@@ -506,7 +506,7 @@
|
|
| 506 |
background: #e3f2fd;
|
| 507 |
color: #1976d2;
|
| 508 |
padding: 0.25rem 0.5rem;
|
| 509 |
-
border-radius:
|
| 510 |
font-size: 0.8rem;
|
| 511 |
font-weight: 500;
|
| 512 |
}
|
|
|
|
| 24 |
|
| 25 |
.modal-content {
|
| 26 |
background: #ffffff;
|
| 27 |
+
border-radius: 0;
|
| 28 |
max-width: 900px;
|
| 29 |
width: 100%;
|
| 30 |
max-height: 90vh;
|
|
|
|
| 34 |
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
| 35 |
border: 1px solid #d0d0d0;
|
| 36 |
animation: slideUp 0.3s ease-out;
|
| 37 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 38 |
display: flex;
|
| 39 |
flex-direction: column;
|
| 40 |
}
|
|
|
|
| 69 |
display: flex;
|
| 70 |
align-items: center;
|
| 71 |
justify-content: center;
|
| 72 |
+
border-radius: 0;
|
| 73 |
transition: all 0.2s;
|
| 74 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 75 |
}
|
| 76 |
|
| 77 |
.modal-close:hover {
|
|
|
|
| 94 |
font-size: 1.5rem;
|
| 95 |
color: #1a1a1a;
|
| 96 |
word-break: break-word;
|
| 97 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 98 |
font-weight: 600;
|
| 99 |
line-height: 1.3;
|
| 100 |
}
|
|
|
|
| 119 |
background: #6a6a6a;
|
| 120 |
color: white;
|
| 121 |
border: none;
|
| 122 |
+
border-radius: 0;
|
| 123 |
cursor: pointer;
|
| 124 |
font-size: 0.85rem;
|
| 125 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 126 |
transition: all 0.2s;
|
| 127 |
font-weight: 500;
|
| 128 |
}
|
|
|
|
| 156 |
border-bottom: 2px solid transparent;
|
| 157 |
cursor: pointer;
|
| 158 |
font-size: 0.9rem;
|
| 159 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 160 |
color: #666;
|
| 161 |
font-weight: 500;
|
| 162 |
margin-bottom: -2px;
|
|
|
|
| 176 |
color: white;
|
| 177 |
font-size: 0.7rem;
|
| 178 |
padding: 0.15rem 0.4rem;
|
| 179 |
+
border-radius: 0;
|
| 180 |
font-weight: 600;
|
| 181 |
margin-left: 0.25rem;
|
| 182 |
}
|
|
|
|
| 215 |
text-transform: uppercase;
|
| 216 |
letter-spacing: 0.5px;
|
| 217 |
font-weight: 600;
|
| 218 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 219 |
}
|
| 220 |
|
| 221 |
.info-value {
|
| 222 |
font-size: 1.1rem;
|
| 223 |
color: #1a1a1a;
|
| 224 |
font-weight: 500;
|
| 225 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 226 |
}
|
| 227 |
|
| 228 |
.info-value.highlight {
|
|
|
|
| 248 |
margin-bottom: 1.5rem;
|
| 249 |
padding: 1rem;
|
| 250 |
background: #fafafa;
|
| 251 |
+
border-radius: 0;
|
| 252 |
border: 1px solid #e0e0e0;
|
| 253 |
}
|
| 254 |
|
|
|
|
| 259 |
letter-spacing: 0.5px;
|
| 260 |
font-weight: 600;
|
| 261 |
margin-bottom: 0.75rem;
|
| 262 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 263 |
}
|
| 264 |
|
| 265 |
.section-content {
|
|
|
|
| 279 |
padding: 0.35rem 0.75rem;
|
| 280 |
background: #e8f4f8;
|
| 281 |
color: #1a1a1a;
|
| 282 |
+
border-radius: 0;
|
| 283 |
font-size: 0.85rem;
|
| 284 |
font-weight: 500;
|
| 285 |
border: 1px solid #c8e6c9;
|
|
|
|
| 316 |
color: #4a4a4a;
|
| 317 |
text-transform: uppercase;
|
| 318 |
letter-spacing: 0.5px;
|
| 319 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 320 |
}
|
| 321 |
|
| 322 |
.modal-info-grid {
|
|
|
|
| 335 |
font-size: 0.875rem;
|
| 336 |
color: #6a6a6a;
|
| 337 |
font-weight: 500;
|
| 338 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 339 |
}
|
| 340 |
|
| 341 |
.modal-info-item span {
|
| 342 |
font-size: 1rem;
|
| 343 |
color: #1a1a1a;
|
| 344 |
font-weight: 500;
|
| 345 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 346 |
}
|
| 347 |
|
| 348 |
.modal-tags {
|
| 349 |
margin: 0;
|
| 350 |
padding: 0.75rem;
|
| 351 |
background: #f5f5f5;
|
| 352 |
+
border-radius: 0;
|
| 353 |
color: #1a1a1a;
|
| 354 |
font-size: 0.9rem;
|
| 355 |
line-height: 1.5;
|
| 356 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 357 |
}
|
| 358 |
|
| 359 |
.modal-footer {
|
|
|
|
| 372 |
background: #1a1a1a;
|
| 373 |
color: #ffffff;
|
| 374 |
text-decoration: none;
|
| 375 |
+
border-radius: 0;
|
| 376 |
font-weight: 500;
|
| 377 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
| 378 |
transition: all 0.2s;
|
| 379 |
border: 1px solid #1a1a1a;
|
| 380 |
}
|
|
|
|
| 420 |
.papers-error {
|
| 421 |
color: #d32f2f;
|
| 422 |
background: #ffebee;
|
| 423 |
+
border-radius: 0;
|
| 424 |
padding: 1rem;
|
| 425 |
}
|
| 426 |
|
|
|
|
| 433 |
.paper-card {
|
| 434 |
background: #f9f9f9;
|
| 435 |
border: 1px solid #e0e0e0;
|
| 436 |
+
border-radius: 0;
|
| 437 |
padding: 1.5rem;
|
| 438 |
transition: box-shadow 0.2s;
|
| 439 |
}
|
|
|
|
| 506 |
background: #e3f2fd;
|
| 507 |
color: #1976d2;
|
| 508 |
padding: 0.25rem 0.5rem;
|
| 509 |
+
border-radius: 0;
|
| 510 |
font-size: 0.8rem;
|
| 511 |
font-weight: 500;
|
| 512 |
}
|
frontend/src/components/modals/ModelModal.tsx
CHANGED
|
@@ -201,14 +201,14 @@ export default function ModelModal({
|
|
| 201 |
className={`modal-tab ${activeTab === 'details' ? 'active' : ''}`}
|
| 202 |
onClick={() => setActiveTab('details')}
|
| 203 |
>
|
| 204 |
-
<span className="tab-icon"
|
| 205 |
<span>Details</span>
|
| 206 |
</button>
|
| 207 |
<button
|
| 208 |
className={`modal-tab ${activeTab === 'files' ? 'active' : ''}`}
|
| 209 |
onClick={() => setActiveTab('files')}
|
| 210 |
>
|
| 211 |
-
<span className="tab-icon"
|
| 212 |
<span>Files</span>
|
| 213 |
</button>
|
| 214 |
{(papers.length > 0 || papersLoading) && (
|
|
@@ -216,7 +216,7 @@ export default function ModelModal({
|
|
| 216 |
className={`modal-tab ${activeTab === 'papers' ? 'active' : ''}`}
|
| 217 |
onClick={() => setActiveTab('papers')}
|
| 218 |
>
|
| 219 |
-
<span className="tab-icon"
|
| 220 |
<span>Papers</span>
|
| 221 |
{papers.length > 0 && <span className="tab-badge">{papers.length}</span>}
|
| 222 |
</button>
|
|
|
|
| 201 |
className={`modal-tab ${activeTab === 'details' ? 'active' : ''}`}
|
| 202 |
onClick={() => setActiveTab('details')}
|
| 203 |
>
|
| 204 |
+
<span className="tab-icon"></span>
|
| 205 |
<span>Details</span>
|
| 206 |
</button>
|
| 207 |
<button
|
| 208 |
className={`modal-tab ${activeTab === 'files' ? 'active' : ''}`}
|
| 209 |
onClick={() => setActiveTab('files')}
|
| 210 |
>
|
| 211 |
+
<span className="tab-icon"></span>
|
| 212 |
<span>Files</span>
|
| 213 |
</button>
|
| 214 |
{(papers.length > 0 || papersLoading) && (
|
|
|
|
| 216 |
className={`modal-tab ${activeTab === 'papers' ? 'active' : ''}`}
|
| 217 |
onClick={() => setActiveTab('papers')}
|
| 218 |
>
|
| 219 |
+
<span className="tab-icon"></span>
|
| 220 |
<span>Papers</span>
|
| 221 |
{papers.length > 0 && <span className="tab-badge">{papers.length}</span>}
|
| 222 |
</button>
|
frontend/src/components/ui/ColorLegend.css
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
position: absolute;
|
| 3 |
background: rgba(255, 255, 255, 0.95);
|
| 4 |
border: 1px solid #e0e0e0;
|
| 5 |
-
border-radius:
|
| 6 |
padding: 0.75rem;
|
| 7 |
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
| 8 |
z-index: 100;
|
|
@@ -63,7 +63,7 @@
|
|
| 63 |
.legend-color {
|
| 64 |
width: 16px;
|
| 65 |
height: 16px;
|
| 66 |
-
border-radius:
|
| 67 |
border: 1px solid #ccc;
|
| 68 |
flex-shrink: 0;
|
| 69 |
}
|
|
@@ -85,7 +85,7 @@
|
|
| 85 |
.legend-gradient {
|
| 86 |
display: flex;
|
| 87 |
height: 20px;
|
| 88 |
-
border-radius:
|
| 89 |
overflow: hidden;
|
| 90 |
border: 1px solid #ccc;
|
| 91 |
}
|
|
|
|
| 2 |
position: absolute;
|
| 3 |
background: rgba(255, 255, 255, 0.95);
|
| 4 |
border: 1px solid #e0e0e0;
|
| 5 |
+
border-radius: 0;
|
| 6 |
padding: 0.75rem;
|
| 7 |
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
| 8 |
z-index: 100;
|
|
|
|
| 63 |
.legend-color {
|
| 64 |
width: 16px;
|
| 65 |
height: 16px;
|
| 66 |
+
border-radius: 0;
|
| 67 |
border: 1px solid #ccc;
|
| 68 |
flex-shrink: 0;
|
| 69 |
}
|
|
|
|
| 85 |
.legend-gradient {
|
| 86 |
display: flex;
|
| 87 |
height: 20px;
|
| 88 |
+
border-radius: 0;
|
| 89 |
overflow: hidden;
|
| 90 |
border: 1px solid #ccc;
|
| 91 |
}
|
frontend/src/components/ui/ErrorBoundary.tsx
CHANGED
|
@@ -65,7 +65,7 @@ class ErrorBoundary extends Component<Props, State> {
|
|
| 65 |
margin: '2rem',
|
| 66 |
background: '#ffebee',
|
| 67 |
border: '1px solid #ffcdd2',
|
| 68 |
-
borderRadius: '
|
| 69 |
color: '#c62828',
|
| 70 |
}}
|
| 71 |
>
|
|
@@ -83,7 +83,7 @@ class ErrorBoundary extends Component<Props, State> {
|
|
| 83 |
marginTop: '0.5rem',
|
| 84 |
padding: '1rem',
|
| 85 |
background: '#fff',
|
| 86 |
-
borderRadius: '
|
| 87 |
overflow: 'auto',
|
| 88 |
fontSize: '0.875rem',
|
| 89 |
}}
|
|
@@ -101,7 +101,7 @@ class ErrorBoundary extends Component<Props, State> {
|
|
| 101 |
background: '#4a4a4a',
|
| 102 |
color: 'white',
|
| 103 |
border: 'none',
|
| 104 |
-
borderRadius: '
|
| 105 |
cursor: 'pointer',
|
| 106 |
}}
|
| 107 |
>
|
|
|
|
| 65 |
margin: '2rem',
|
| 66 |
background: '#ffebee',
|
| 67 |
border: '1px solid #ffcdd2',
|
| 68 |
+
borderRadius: '0',
|
| 69 |
color: '#c62828',
|
| 70 |
}}
|
| 71 |
>
|
|
|
|
| 83 |
marginTop: '0.5rem',
|
| 84 |
padding: '1rem',
|
| 85 |
background: '#fff',
|
| 86 |
+
borderRadius: '0',
|
| 87 |
overflow: 'auto',
|
| 88 |
fontSize: '0.875rem',
|
| 89 |
}}
|
|
|
|
| 101 |
background: '#4a4a4a',
|
| 102 |
color: 'white',
|
| 103 |
border: 'none',
|
| 104 |
+
borderRadius: '0',
|
| 105 |
cursor: 'pointer',
|
| 106 |
}}
|
| 107 |
>
|
frontend/src/components/ui/LiveModelCount.css
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
gap: 0.75rem;
|
| 6 |
padding: 0.5rem 1rem;
|
| 7 |
background: rgba(255, 255, 255, 0.15);
|
| 8 |
-
border-radius:
|
| 9 |
backdrop-filter: blur(10px);
|
| 10 |
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 11 |
}
|
|
@@ -40,7 +40,7 @@
|
|
| 40 |
background: rgba(255, 255, 255, 0.2);
|
| 41 |
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 42 |
color: white;
|
| 43 |
-
border-radius:
|
| 44 |
padding: 0.25rem 0.5rem;
|
| 45 |
cursor: pointer;
|
| 46 |
font-size: 1rem;
|
|
@@ -76,7 +76,7 @@
|
|
| 76 |
.live-model-count-full {
|
| 77 |
background: white;
|
| 78 |
border: 1px solid #e0e0e0;
|
| 79 |
-
border-radius:
|
| 80 |
padding: 1rem;
|
| 81 |
margin-bottom: 1rem;
|
| 82 |
}
|
|
@@ -99,7 +99,7 @@
|
|
| 99 |
.refresh-btn-small {
|
| 100 |
background: #f5f5f5;
|
| 101 |
border: 1px solid #ddd;
|
| 102 |
-
border-radius:
|
| 103 |
padding: 0.25rem 0.5rem;
|
| 104 |
cursor: pointer;
|
| 105 |
font-size: 0.875rem;
|
|
@@ -133,7 +133,7 @@
|
|
| 133 |
text-align: center;
|
| 134 |
padding: 1.25rem 1rem;
|
| 135 |
background: #2d2d2d;
|
| 136 |
-
border-radius:
|
| 137 |
color: white;
|
| 138 |
border: 1px solid #404040;
|
| 139 |
}
|
|
|
|
| 5 |
gap: 0.75rem;
|
| 6 |
padding: 0.5rem 1rem;
|
| 7 |
background: rgba(255, 255, 255, 0.15);
|
| 8 |
+
border-radius: 0;
|
| 9 |
backdrop-filter: blur(10px);
|
| 10 |
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 11 |
}
|
|
|
|
| 40 |
background: rgba(255, 255, 255, 0.2);
|
| 41 |
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 42 |
color: white;
|
| 43 |
+
border-radius: 0;
|
| 44 |
padding: 0.25rem 0.5rem;
|
| 45 |
cursor: pointer;
|
| 46 |
font-size: 1rem;
|
|
|
|
| 76 |
.live-model-count-full {
|
| 77 |
background: white;
|
| 78 |
border: 1px solid #e0e0e0;
|
| 79 |
+
border-radius: 0;
|
| 80 |
padding: 1rem;
|
| 81 |
margin-bottom: 1rem;
|
| 82 |
}
|
|
|
|
| 99 |
.refresh-btn-small {
|
| 100 |
background: #f5f5f5;
|
| 101 |
border: 1px solid #ddd;
|
| 102 |
+
border-radius: 0;
|
| 103 |
padding: 0.25rem 0.5rem;
|
| 104 |
cursor: pointer;
|
| 105 |
font-size: 0.875rem;
|
|
|
|
| 133 |
text-align: center;
|
| 134 |
padding: 1.25rem 1rem;
|
| 135 |
background: #2d2d2d;
|
| 136 |
+
border-radius: 0;
|
| 137 |
color: white;
|
| 138 |
border: 1px solid #404040;
|
| 139 |
}
|
frontend/src/components/ui/LiveModelCount.tsx
CHANGED
|
@@ -136,7 +136,7 @@ export default function LiveModelCount({ compact = true }: { compact?: boolean }
|
|
| 136 |
padding: '1rem',
|
| 137 |
background: '#ffebee',
|
| 138 |
border: '1px solid #ffcdd2',
|
| 139 |
-
borderRadius: '
|
| 140 |
color: '#c62828',
|
| 141 |
textAlign: 'center'
|
| 142 |
}}>
|
|
@@ -146,7 +146,7 @@ export default function LiveModelCount({ compact = true }: { compact?: boolean }
|
|
| 146 |
style={{
|
| 147 |
background: '#f5f5f5',
|
| 148 |
border: '1px solid #ddd',
|
| 149 |
-
borderRadius: '
|
| 150 |
padding: '0.5rem 1rem',
|
| 151 |
cursor: 'pointer',
|
| 152 |
fontSize: '0.875rem',
|
|
|
|
| 136 |
padding: '1rem',
|
| 137 |
background: '#ffebee',
|
| 138 |
border: '1px solid #ffcdd2',
|
| 139 |
+
borderRadius: '0',
|
| 140 |
color: '#c62828',
|
| 141 |
textAlign: 'center'
|
| 142 |
}}>
|
|
|
|
| 146 |
style={{
|
| 147 |
background: '#f5f5f5',
|
| 148 |
border: '1px solid #ddd',
|
| 149 |
+
borderRadius: '0',
|
| 150 |
padding: '0.5rem 1rem',
|
| 151 |
cursor: 'pointer',
|
| 152 |
fontSize: '0.875rem',
|
frontend/src/components/ui/ModelCountTracker.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
.model-count-tracker {
|
| 2 |
background: white;
|
| 3 |
-
border-radius:
|
| 4 |
padding: 1.5rem;
|
| 5 |
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 6 |
margin-bottom: 1rem;
|
|
@@ -42,7 +42,7 @@
|
|
| 42 |
.tracker-error {
|
| 43 |
color: #d32f2f;
|
| 44 |
background: #ffebee;
|
| 45 |
-
border-radius:
|
| 46 |
padding: 1rem;
|
| 47 |
margin-bottom: 1rem;
|
| 48 |
}
|
|
@@ -53,7 +53,7 @@
|
|
| 53 |
background: #1976d2;
|
| 54 |
color: white;
|
| 55 |
border: none;
|
| 56 |
-
border-radius:
|
| 57 |
cursor: pointer;
|
| 58 |
}
|
| 59 |
|
|
@@ -71,7 +71,7 @@
|
|
| 71 |
text-align: center;
|
| 72 |
padding: 1.5rem;
|
| 73 |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 74 |
-
border-radius:
|
| 75 |
color: white;
|
| 76 |
}
|
| 77 |
|
|
@@ -96,7 +96,7 @@
|
|
| 96 |
.growth-stats {
|
| 97 |
padding: 1rem;
|
| 98 |
background: #f5f5f5;
|
| 99 |
-
border-radius:
|
| 100 |
}
|
| 101 |
|
| 102 |
.growth-stats h4 {
|
|
@@ -115,7 +115,7 @@
|
|
| 115 |
text-align: center;
|
| 116 |
padding: 1rem;
|
| 117 |
background: white;
|
| 118 |
-
border-radius:
|
| 119 |
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 120 |
}
|
| 121 |
|
|
@@ -134,7 +134,7 @@
|
|
| 134 |
.breakdown {
|
| 135 |
padding: 1rem;
|
| 136 |
background: #f5f5f5;
|
| 137 |
-
border-radius:
|
| 138 |
}
|
| 139 |
|
| 140 |
.breakdown h4 {
|
|
@@ -155,7 +155,7 @@
|
|
| 155 |
align-items: center;
|
| 156 |
padding: 0.75rem;
|
| 157 |
background: white;
|
| 158 |
-
border-radius:
|
| 159 |
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 160 |
}
|
| 161 |
|
|
@@ -174,7 +174,7 @@
|
|
| 174 |
background: #1976d2;
|
| 175 |
color: white;
|
| 176 |
border: none;
|
| 177 |
-
border-radius:
|
| 178 |
font-size: 1rem;
|
| 179 |
cursor: pointer;
|
| 180 |
transition: background 0.2s;
|
|
|
|
| 1 |
.model-count-tracker {
|
| 2 |
background: white;
|
| 3 |
+
border-radius: 0;
|
| 4 |
padding: 1.5rem;
|
| 5 |
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 6 |
margin-bottom: 1rem;
|
|
|
|
| 42 |
.tracker-error {
|
| 43 |
color: #d32f2f;
|
| 44 |
background: #ffebee;
|
| 45 |
+
border-radius: 0;
|
| 46 |
padding: 1rem;
|
| 47 |
margin-bottom: 1rem;
|
| 48 |
}
|
|
|
|
| 53 |
background: #1976d2;
|
| 54 |
color: white;
|
| 55 |
border: none;
|
| 56 |
+
border-radius: 0;
|
| 57 |
cursor: pointer;
|
| 58 |
}
|
| 59 |
|
|
|
|
| 71 |
text-align: center;
|
| 72 |
padding: 1.5rem;
|
| 73 |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 74 |
+
border-radius: 0;
|
| 75 |
color: white;
|
| 76 |
}
|
| 77 |
|
|
|
|
| 96 |
.growth-stats {
|
| 97 |
padding: 1rem;
|
| 98 |
background: #f5f5f5;
|
| 99 |
+
border-radius: 0;
|
| 100 |
}
|
| 101 |
|
| 102 |
.growth-stats h4 {
|
|
|
|
| 115 |
text-align: center;
|
| 116 |
padding: 1rem;
|
| 117 |
background: white;
|
| 118 |
+
border-radius: 0;
|
| 119 |
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 120 |
}
|
| 121 |
|
|
|
|
| 134 |
.breakdown {
|
| 135 |
padding: 1rem;
|
| 136 |
background: #f5f5f5;
|
| 137 |
+
border-radius: 0;
|
| 138 |
}
|
| 139 |
|
| 140 |
.breakdown h4 {
|
|
|
|
| 155 |
align-items: center;
|
| 156 |
padding: 0.75rem;
|
| 157 |
background: white;
|
| 158 |
+
border-radius: 0;
|
| 159 |
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 160 |
}
|
| 161 |
|
|
|
|
| 174 |
background: #1976d2;
|
| 175 |
color: white;
|
| 176 |
border: none;
|
| 177 |
+
border-radius: 0;
|
| 178 |
font-size: 1rem;
|
| 179 |
cursor: pointer;
|
| 180 |
transition: background 0.2s;
|
frontend/src/components/ui/ModelTooltip.tsx
CHANGED
|
@@ -116,7 +116,7 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP
|
|
| 116 |
background: 'rgba(0, 0, 0, 0.9)',
|
| 117 |
color: 'white',
|
| 118 |
padding: '12px 16px',
|
| 119 |
-
borderRadius: '
|
| 120 |
fontSize: '13px',
|
| 121 |
maxWidth: '350px',
|
| 122 |
zIndex: 10000,
|
|
|
|
| 116 |
background: 'rgba(0, 0, 0, 0.9)',
|
| 117 |
color: 'white',
|
| 118 |
padding: '12px 16px',
|
| 119 |
+
borderRadius: '0',
|
| 120 |
fontSize: '13px',
|
| 121 |
maxWidth: '350px',
|
| 122 |
zIndex: 10000,
|
frontend/src/components/ui/VirtualSearchResults.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Virtual scrolling component for large lists (search results, model lists).
|
| 3 |
+
* Renders only visible items for 10-100x better performance.
|
| 4 |
+
*/
|
| 5 |
+
import React from 'react';
|
| 6 |
+
import { FixedSizeList as List } from 'react-window';
|
| 7 |
+
import AutoSizer from 'react-virtualized-auto-sizer';
|
| 8 |
+
import { SearchResult } from '../../types';
|
| 9 |
+
|
| 10 |
+
interface VirtualSearchResultsProps {
|
| 11 |
+
results: SearchResult[];
|
| 12 |
+
onSelect: (result: SearchResult) => void;
|
| 13 |
+
selectedIndex?: number;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const VirtualSearchResults: React.FC<VirtualSearchResultsProps> = ({
|
| 17 |
+
results,
|
| 18 |
+
onSelect,
|
| 19 |
+
selectedIndex = -1,
|
| 20 |
+
}) => {
|
| 21 |
+
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
|
| 22 |
+
const result = results[index];
|
| 23 |
+
const isSelected = index === selectedIndex;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div
|
| 27 |
+
style={{
|
| 28 |
+
...style,
|
| 29 |
+
cursor: 'pointer',
|
| 30 |
+
padding: '8px 12px',
|
| 31 |
+
backgroundColor: isSelected ? 'var(--accent-color)' : 'transparent',
|
| 32 |
+
borderBottom: '1px solid var(--border-color)',
|
| 33 |
+
}}
|
| 34 |
+
onClick={() => onSelect(result)}
|
| 35 |
+
onMouseEnter={(e) => {
|
| 36 |
+
if (!isSelected) {
|
| 37 |
+
e.currentTarget.style.backgroundColor = 'var(--hover-color)';
|
| 38 |
+
}
|
| 39 |
+
}}
|
| 40 |
+
onMouseLeave={(e) => {
|
| 41 |
+
if (!isSelected) {
|
| 42 |
+
e.currentTarget.style.backgroundColor = 'transparent';
|
| 43 |
+
}
|
| 44 |
+
}}
|
| 45 |
+
>
|
| 46 |
+
<div style={{ fontWeight: 500 }}>{result.model_id}</div>
|
| 47 |
+
{result.library_name && (
|
| 48 |
+
<div style={{ fontSize: '0.875rem', opacity: 0.7, marginTop: '2px' }}>
|
| 49 |
+
{result.library_name} {result.pipeline_tag && `• ${result.pipeline_tag}`}
|
| 50 |
+
</div>
|
| 51 |
+
)}
|
| 52 |
+
{(result.downloads || result.likes) && (
|
| 53 |
+
<div style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px' }}>
|
| 54 |
+
{result.downloads?.toLocaleString()} downloads • {result.likes?.toLocaleString()} likes
|
| 55 |
+
</div>
|
| 56 |
+
)}
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<AutoSizer>
|
| 63 |
+
{({ height, width }: { height: number; width: number }) => (
|
| 64 |
+
<List
|
| 65 |
+
height={Math.min(height, 400)}
|
| 66 |
+
itemCount={results.length}
|
| 67 |
+
itemSize={70}
|
| 68 |
+
width={width}
|
| 69 |
+
overscanCount={5}
|
| 70 |
+
>
|
| 71 |
+
{Row}
|
| 72 |
+
</List>
|
| 73 |
+
)}
|
| 74 |
+
</AutoSizer>
|
| 75 |
+
);
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
export default VirtualSearchResults;
|
| 79 |
+
|
| 80 |
+
|
frontend/src/components/visualizations/DistanceHeatmap.tsx
CHANGED
|
@@ -63,9 +63,9 @@ export default function DistanceHeatmap({
|
|
| 63 |
background: 'rgba(0, 0, 0, 0.7)',
|
| 64 |
color: 'white',
|
| 65 |
padding: '8px 12px',
|
| 66 |
-
borderRadius: '
|
| 67 |
fontSize: '11px',
|
| 68 |
-
fontFamily: "'
|
| 69 |
}}
|
| 70 |
>
|
| 71 |
<div style={{ fontWeight: 600, marginBottom: '4px' }}>Distance Heatmap</div>
|
|
|
|
| 63 |
background: 'rgba(0, 0, 0, 0.7)',
|
| 64 |
color: 'white',
|
| 65 |
padding: '8px 12px',
|
| 66 |
+
borderRadius: '0',
|
| 67 |
fontSize: '11px',
|
| 68 |
+
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif"
|
| 69 |
}}
|
| 70 |
>
|
| 71 |
<div style={{ fontWeight: 600, marginBottom: '4px' }}>Distance Heatmap</div>
|
frontend/src/components/visualizations/DistributionView.css
CHANGED
|
@@ -32,7 +32,7 @@
|
|
| 32 |
.distribution-chart {
|
| 33 |
background: var(--bg-primary, #ffffff);
|
| 34 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 35 |
-
border-radius:
|
| 36 |
padding: 1rem;
|
| 37 |
}
|
| 38 |
|
|
@@ -66,7 +66,7 @@
|
|
| 66 |
flex: 1;
|
| 67 |
height: 24px;
|
| 68 |
background: var(--bg-secondary, #f5f5f5);
|
| 69 |
-
border-radius:
|
| 70 |
overflow: hidden;
|
| 71 |
position: relative;
|
| 72 |
}
|
|
@@ -74,7 +74,7 @@
|
|
| 74 |
.distribution-bar {
|
| 75 |
height: 100%;
|
| 76 |
background: linear-gradient(90deg, var(--accent-color, #4a90e2), #6ba3e8);
|
| 77 |
-
border-radius:
|
| 78 |
display: flex;
|
| 79 |
align-items: center;
|
| 80 |
justify-content: flex-end;
|
|
|
|
| 32 |
.distribution-chart {
|
| 33 |
background: var(--bg-primary, #ffffff);
|
| 34 |
border: 1px solid var(--border-color, #e0e0e0);
|
| 35 |
+
border-radius: 0;
|
| 36 |
padding: 1rem;
|
| 37 |
}
|
| 38 |
|
|
|
|
| 66 |
flex: 1;
|
| 67 |
height: 24px;
|
| 68 |
background: var(--bg-secondary, #f5f5f5);
|
| 69 |
+
border-radius: 0;
|
| 70 |
overflow: hidden;
|
| 71 |
position: relative;
|
| 72 |
}
|
|
|
|
| 74 |
.distribution-bar {
|
| 75 |
height: 100%;
|
| 76 |
background: linear-gradient(90deg, var(--accent-color, #4a90e2), #6ba3e8);
|
| 77 |
+
border-radius: 0;
|
| 78 |
display: flex;
|
| 79 |
align-items: center;
|
| 80 |
justify-content: flex-end;
|
frontend/src/components/visualizations/EnhancedScatterPlot.tsx
DELETED
|
@@ -1,638 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Enhanced D3.js scatter plot with zoom, pan, brush selection, and smooth animations.
|
| 3 |
-
* Interactive latent space navigator with dynamic interactions.
|
| 4 |
-
*/
|
| 5 |
-
import React, { useMemo, useRef, useEffect, useState, useCallback } from 'react';
|
| 6 |
-
import * as d3 from 'd3';
|
| 7 |
-
import { ModelPoint } from '../../types';
|
| 8 |
-
|
| 9 |
-
interface EnhancedScatterPlotProps {
|
| 10 |
-
width: number;
|
| 11 |
-
height: number;
|
| 12 |
-
data: ModelPoint[];
|
| 13 |
-
colorBy: string;
|
| 14 |
-
sizeBy: string;
|
| 15 |
-
margin?: { top: number; right: number; bottom: number; left: number };
|
| 16 |
-
onPointClick?: (model: ModelPoint) => void;
|
| 17 |
-
onBrush?: (selected: ModelPoint[]) => void;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
const defaultMargin = { top: 40, right: 40, bottom: 60, left: 60 };
|
| 21 |
-
|
| 22 |
-
export default function EnhancedScatterPlot({
|
| 23 |
-
width,
|
| 24 |
-
height,
|
| 25 |
-
data,
|
| 26 |
-
colorBy,
|
| 27 |
-
sizeBy,
|
| 28 |
-
margin = defaultMargin,
|
| 29 |
-
onPointClick,
|
| 30 |
-
onBrush,
|
| 31 |
-
}: EnhancedScatterPlotProps) {
|
| 32 |
-
const svgRef = useRef<SVGSVGElement>(null);
|
| 33 |
-
const gRef = useRef<SVGGElement | null>(null);
|
| 34 |
-
const [selectedPoints, setSelectedPoints] = useState<Set<string>>(new Set());
|
| 35 |
-
const [hoveredPoint, setHoveredPoint] = useState<string | null>(null);
|
| 36 |
-
const [transform, setTransform] = useState<d3.ZoomTransform>(d3.zoomIdentity);
|
| 37 |
-
const zoomRef = useRef<d3.ZoomBehavior<Element, unknown> | null>(null);
|
| 38 |
-
|
| 39 |
-
// Sample data for very large datasets to improve performance and navigability
|
| 40 |
-
const sampledData = useMemo(() => {
|
| 41 |
-
// Reduced limit for better sparsity and navigability
|
| 42 |
-
const renderLimit = 30000; // Reduced from 50K to 30K
|
| 43 |
-
if (data.length <= renderLimit) return data;
|
| 44 |
-
|
| 45 |
-
// Use step-based sampling for better distribution
|
| 46 |
-
const step = Math.ceil(data.length / renderLimit);
|
| 47 |
-
const sampled: typeof data = [];
|
| 48 |
-
for (let i = 0; i < data.length; i += step) {
|
| 49 |
-
sampled.push(data[i]);
|
| 50 |
-
}
|
| 51 |
-
return sampled;
|
| 52 |
-
}, [data]);
|
| 53 |
-
|
| 54 |
-
const { xScaleBase, yScaleBase, colorScale, sizeScale, useLogSize } = useMemo(() => {
|
| 55 |
-
if (sampledData.length === 0) {
|
| 56 |
-
return {
|
| 57 |
-
xScaleBase: d3.scaleLinear(),
|
| 58 |
-
yScaleBase: d3.scaleLinear(),
|
| 59 |
-
colorScale: d3.scaleOrdinal(),
|
| 60 |
-
sizeScale: d3.scaleLinear(),
|
| 61 |
-
};
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
const xExtent = d3.extent(sampledData, (d) => d.x) as [number, number];
|
| 65 |
-
const yExtent = d3.extent(sampledData, (d) => d.y) as [number, number];
|
| 66 |
-
|
| 67 |
-
const xScaleBase = d3
|
| 68 |
-
.scaleLinear()
|
| 69 |
-
.domain(xExtent)
|
| 70 |
-
.range([0, width - margin.left - margin.right])
|
| 71 |
-
.nice();
|
| 72 |
-
|
| 73 |
-
const yScaleBase = d3
|
| 74 |
-
.scaleLinear()
|
| 75 |
-
.domain(yExtent)
|
| 76 |
-
.range([height - margin.top - margin.bottom, 0])
|
| 77 |
-
.nice();
|
| 78 |
-
|
| 79 |
-
// Color scale
|
| 80 |
-
const isCategorical = colorBy === 'library_name' || colorBy === 'pipeline_tag';
|
| 81 |
-
let colorScale: d3.ScaleOrdinal<string, string> | d3.ScaleSequential<string, never> | ((d: ModelPoint) => string);
|
| 82 |
-
|
| 83 |
-
if (isCategorical) {
|
| 84 |
-
const categories = Array.from(new Set(sampledData.map((d) => {
|
| 85 |
-
if (colorBy === 'library_name') return d.library_name || 'unknown';
|
| 86 |
-
return d.pipeline_tag || 'unknown';
|
| 87 |
-
})));
|
| 88 |
-
colorScale = d3.scaleOrdinal(d3.schemeCategory10).domain(categories);
|
| 89 |
-
} else {
|
| 90 |
-
const values = sampledData.map((d) => {
|
| 91 |
-
if (colorBy === 'downloads') return d.downloads;
|
| 92 |
-
return d.likes;
|
| 93 |
-
});
|
| 94 |
-
const extent = d3.extent(values) as [number, number];
|
| 95 |
-
// Use logarithmic scale for downloads/likes (heavily skewed distributions)
|
| 96 |
-
if (colorBy === 'downloads' || colorBy === 'likes') {
|
| 97 |
-
const logExtent: [number, number] = [
|
| 98 |
-
Math.log10(extent[0] + 1),
|
| 99 |
-
Math.log10(extent[1] + 1)
|
| 100 |
-
];
|
| 101 |
-
const originalScale = d3.scaleSequential(d3.interpolateViridis).domain(logExtent);
|
| 102 |
-
// Wrap to apply log transform
|
| 103 |
-
colorScale = ((d: ModelPoint) => {
|
| 104 |
-
const val = colorBy === 'downloads' ? d.downloads : d.likes;
|
| 105 |
-
return originalScale(Math.log10(val + 1));
|
| 106 |
-
});
|
| 107 |
-
} else {
|
| 108 |
-
colorScale = d3.scaleSequential(d3.interpolateViridis).domain(extent);
|
| 109 |
-
}
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
// Size scale with logarithmic scaling for better representation of skewed distributions
|
| 113 |
-
const sizeValues = sampledData.map((d) => {
|
| 114 |
-
if (sizeBy === 'downloads') return d.downloads;
|
| 115 |
-
if (sizeBy === 'likes') return d.likes;
|
| 116 |
-
return 10;
|
| 117 |
-
});
|
| 118 |
-
const sizeExtent = d3.extent(sizeValues) as [number, number];
|
| 119 |
-
// Use logarithmic scale for downloads/likes
|
| 120 |
-
const useLogSize = sizeBy === 'downloads' || sizeBy === 'likes';
|
| 121 |
-
let sizeScale: ReturnType<typeof d3.scaleSqrt> | ((d: ModelPoint) => number);
|
| 122 |
-
if (useLogSize) {
|
| 123 |
-
const logExtent: [number, number] = [
|
| 124 |
-
Math.log10(sizeExtent[0] + 1),
|
| 125 |
-
Math.log10(sizeExtent[1] + 1)
|
| 126 |
-
];
|
| 127 |
-
const logScale = d3.scaleLinear().domain(logExtent).range([3, 20]);
|
| 128 |
-
sizeScale = ((d: ModelPoint): number => {
|
| 129 |
-
const val = sizeBy === 'downloads' ? d.downloads : d.likes;
|
| 130 |
-
return logScale(Math.log10(val + 1));
|
| 131 |
-
});
|
| 132 |
-
} else {
|
| 133 |
-
sizeScale = d3
|
| 134 |
-
.scaleSqrt()
|
| 135 |
-
.domain(sizeExtent)
|
| 136 |
-
.range([3, 20]);
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
return { xScaleBase, yScaleBase, colorScale, sizeScale, useLogSize };
|
| 140 |
-
}, [sampledData, width, height, margin, colorBy, sizeBy]);
|
| 141 |
-
|
| 142 |
-
// Apply zoom transform to scales - throttle updates for performance
|
| 143 |
-
const transformRef = useRef(transform);
|
| 144 |
-
transformRef.current = transform;
|
| 145 |
-
|
| 146 |
-
const xScale = useMemo(() => {
|
| 147 |
-
const scale = xScaleBase.copy();
|
| 148 |
-
return transform.rescaleX(scale);
|
| 149 |
-
}, [xScaleBase, transform.k, transform.x, transform.y]);
|
| 150 |
-
|
| 151 |
-
const yScale = useMemo(() => {
|
| 152 |
-
const scale = yScaleBase.copy();
|
| 153 |
-
return transform.rescaleY(scale);
|
| 154 |
-
}, [yScaleBase, transform.k, transform.x, transform.y]);
|
| 155 |
-
|
| 156 |
-
// Reset zoom handler
|
| 157 |
-
const resetZoom = useCallback(() => {
|
| 158 |
-
if (svgRef.current && zoomRef.current) {
|
| 159 |
-
d3.select(svgRef.current).transition().duration(750).call(
|
| 160 |
-
zoomRef.current.transform as any,
|
| 161 |
-
d3.zoomIdentity
|
| 162 |
-
);
|
| 163 |
-
setTransform(d3.zoomIdentity);
|
| 164 |
-
}
|
| 165 |
-
}, []);
|
| 166 |
-
|
| 167 |
-
useEffect(() => {
|
| 168 |
-
if (!svgRef.current || sampledData.length === 0) return;
|
| 169 |
-
|
| 170 |
-
const svg = d3.select(svgRef.current);
|
| 171 |
-
svg.selectAll('*').remove();
|
| 172 |
-
|
| 173 |
-
// Add zoom controls
|
| 174 |
-
const controls = svg
|
| 175 |
-
.append('g')
|
| 176 |
-
.attr('class', 'zoom-controls')
|
| 177 |
-
.attr('transform', `translate(${width - 120}, 20)`);
|
| 178 |
-
|
| 179 |
-
controls
|
| 180 |
-
.append('rect')
|
| 181 |
-
.attr('width', 100)
|
| 182 |
-
.attr('height', 60)
|
| 183 |
-
.attr('fill', 'rgba(255, 255, 255, 0.95)')
|
| 184 |
-
.attr('stroke', '#d0d0d0')
|
| 185 |
-
.attr('rx', 2)
|
| 186 |
-
.style('cursor', 'pointer');
|
| 187 |
-
|
| 188 |
-
controls
|
| 189 |
-
.append('text')
|
| 190 |
-
.attr('x', 50)
|
| 191 |
-
.attr('y', 20)
|
| 192 |
-
.attr('text-anchor', 'middle')
|
| 193 |
-
.attr('font-size', '12px')
|
| 194 |
-
.attr('font-weight', 'bold')
|
| 195 |
-
.text('Zoom Controls');
|
| 196 |
-
|
| 197 |
-
const zoomIn = controls
|
| 198 |
-
.append('g')
|
| 199 |
-
.attr('class', 'zoom-in')
|
| 200 |
-
.attr('transform', 'translate(20, 30)')
|
| 201 |
-
.style('cursor', 'pointer');
|
| 202 |
-
|
| 203 |
-
zoomIn
|
| 204 |
-
.append('rect')
|
| 205 |
-
.attr('width', 25)
|
| 206 |
-
.attr('height', 25)
|
| 207 |
-
.attr('fill', '#4a4a4a')
|
| 208 |
-
.attr('rx', 2)
|
| 209 |
-
.on('click', () => {
|
| 210 |
-
if (svgRef.current && zoomRef.current) {
|
| 211 |
-
const svgNode = svgRef.current;
|
| 212 |
-
const [x, y] = [width / 2, height / 2];
|
| 213 |
-
d3.select(svgNode).transition().duration(300).call(
|
| 214 |
-
zoomRef.current.scaleBy as any,
|
| 215 |
-
1.5
|
| 216 |
-
);
|
| 217 |
-
}
|
| 218 |
-
});
|
| 219 |
-
|
| 220 |
-
zoomIn
|
| 221 |
-
.append('text')
|
| 222 |
-
.attr('x', 12.5)
|
| 223 |
-
.attr('y', 17)
|
| 224 |
-
.attr('text-anchor', 'middle')
|
| 225 |
-
.attr('fill', 'white')
|
| 226 |
-
.attr('font-size', '16px')
|
| 227 |
-
.text('+');
|
| 228 |
-
|
| 229 |
-
const zoomOut = controls
|
| 230 |
-
.append('g')
|
| 231 |
-
.attr('class', 'zoom-out')
|
| 232 |
-
.attr('transform', 'translate(55, 30)')
|
| 233 |
-
.style('cursor', 'pointer');
|
| 234 |
-
|
| 235 |
-
zoomOut
|
| 236 |
-
.append('rect')
|
| 237 |
-
.attr('width', 25)
|
| 238 |
-
.attr('height', 25)
|
| 239 |
-
.attr('fill', '#6a6a6a')
|
| 240 |
-
.attr('rx', 2)
|
| 241 |
-
.on('click', () => {
|
| 242 |
-
if (svgRef.current && zoomRef.current) {
|
| 243 |
-
const svgNode = svgRef.current;
|
| 244 |
-
d3.select(svgNode).transition().duration(300).call(
|
| 245 |
-
zoomRef.current.scaleBy as any,
|
| 246 |
-
1 / 1.5
|
| 247 |
-
);
|
| 248 |
-
}
|
| 249 |
-
});
|
| 250 |
-
|
| 251 |
-
zoomOut
|
| 252 |
-
.append('text')
|
| 253 |
-
.attr('x', 12.5)
|
| 254 |
-
.attr('y', 17)
|
| 255 |
-
.attr('text-anchor', 'middle')
|
| 256 |
-
.attr('fill', 'white')
|
| 257 |
-
.attr('font-size', '18px')
|
| 258 |
-
.text('−');
|
| 259 |
-
|
| 260 |
-
const resetBtn = controls
|
| 261 |
-
.append('g')
|
| 262 |
-
.attr('class', 'reset-zoom')
|
| 263 |
-
.attr('transform', 'translate(37.5, 55)')
|
| 264 |
-
.style('cursor', 'pointer');
|
| 265 |
-
|
| 266 |
-
resetBtn
|
| 267 |
-
.append('text')
|
| 268 |
-
.attr('text-anchor', 'middle')
|
| 269 |
-
.attr('font-size', '10px')
|
| 270 |
-
.attr('fill', '#6a6a6a')
|
| 271 |
-
.text('Reset')
|
| 272 |
-
.on('click', resetZoom);
|
| 273 |
-
|
| 274 |
-
const g = svg
|
| 275 |
-
.append('g')
|
| 276 |
-
.attr('class', 'main-group')
|
| 277 |
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 278 |
-
|
| 279 |
-
gRef.current = g.node() as SVGGElement;
|
| 280 |
-
|
| 281 |
-
const xMax = width - margin.left - margin.right;
|
| 282 |
-
const yMax = height - margin.top - margin.bottom;
|
| 283 |
-
|
| 284 |
-
// Setup zoom behavior
|
| 285 |
-
const zoom = d3
|
| 286 |
-
.zoom<Element, unknown>()
|
| 287 |
-
.scaleExtent([0.1, 20])
|
| 288 |
-
.translateExtent([
|
| 289 |
-
[-margin.left, -margin.top],
|
| 290 |
-
[width - margin.right, height - margin.bottom],
|
| 291 |
-
])
|
| 292 |
-
.on('zoom', (event) => {
|
| 293 |
-
setTransform(event.transform);
|
| 294 |
-
g.attr('transform', `translate(${margin.left},${margin.top}) ${event.transform}`);
|
| 295 |
-
});
|
| 296 |
-
|
| 297 |
-
zoomRef.current = zoom;
|
| 298 |
-
svg.call(zoom as any);
|
| 299 |
-
|
| 300 |
-
// Add grid (will be updated on zoom)
|
| 301 |
-
const xAxisGrid = d3
|
| 302 |
-
.axisBottom(xScale)
|
| 303 |
-
.tickSize(-yMax)
|
| 304 |
-
.tickFormat(() => '');
|
| 305 |
-
const yAxisGrid = d3
|
| 306 |
-
.axisLeft(yScale)
|
| 307 |
-
.tickSize(-xMax)
|
| 308 |
-
.tickFormat(() => '');
|
| 309 |
-
|
| 310 |
-
g.append('g')
|
| 311 |
-
.attr('class', 'grid')
|
| 312 |
-
.attr('data-axis', 'x')
|
| 313 |
-
.attr('transform', `translate(0,${yMax})`)
|
| 314 |
-
.call(xAxisGrid)
|
| 315 |
-
.selectAll('line')
|
| 316 |
-
.attr('stroke-dasharray', '3,3')
|
| 317 |
-
.attr('opacity', 0.2)
|
| 318 |
-
.attr('stroke', '#d0d0d0');
|
| 319 |
-
|
| 320 |
-
g.append('g')
|
| 321 |
-
.attr('class', 'grid')
|
| 322 |
-
.attr('data-axis', 'y')
|
| 323 |
-
.call(yAxisGrid)
|
| 324 |
-
.selectAll('line')
|
| 325 |
-
.attr('stroke-dasharray', '3,3')
|
| 326 |
-
.attr('opacity', 0.2)
|
| 327 |
-
.attr('stroke', '#d0d0d0');
|
| 328 |
-
|
| 329 |
-
// Add axes
|
| 330 |
-
const xAxis = d3.axisBottom(xScale);
|
| 331 |
-
const yAxis = d3.axisLeft(yScale);
|
| 332 |
-
|
| 333 |
-
const xAxisG = g
|
| 334 |
-
.append('g')
|
| 335 |
-
.attr('class', 'x-axis')
|
| 336 |
-
.attr('transform', `translate(0,${yMax})`)
|
| 337 |
-
.call(xAxis);
|
| 338 |
-
|
| 339 |
-
xAxisG
|
| 340 |
-
.append('text')
|
| 341 |
-
.attr('x', xMax / 2)
|
| 342 |
-
.attr('y', 40)
|
| 343 |
-
.style('text-anchor', 'middle')
|
| 344 |
-
.style('font-size', '12px')
|
| 345 |
-
.text('Dimension 1');
|
| 346 |
-
|
| 347 |
-
const yAxisG = g
|
| 348 |
-
.append('g')
|
| 349 |
-
.attr('class', 'y-axis')
|
| 350 |
-
.call(yAxis);
|
| 351 |
-
|
| 352 |
-
yAxisG
|
| 353 |
-
.append('text')
|
| 354 |
-
.attr('transform', 'rotate(-90)')
|
| 355 |
-
.attr('y', -50)
|
| 356 |
-
.attr('x', -yMax / 2)
|
| 357 |
-
.style('text-anchor', 'middle')
|
| 358 |
-
.style('font-size', '12px')
|
| 359 |
-
.text('Dimension 2');
|
| 360 |
-
|
| 361 |
-
// Add brush for selection (with zoom transform support)
|
| 362 |
-
const brush = d3
|
| 363 |
-
.brush<unknown>()
|
| 364 |
-
.extent([
|
| 365 |
-
[0, 0],
|
| 366 |
-
[xMax, yMax],
|
| 367 |
-
])
|
| 368 |
-
.on('end', (event) => {
|
| 369 |
-
if (!event.selection) {
|
| 370 |
-
setSelectedPoints(new Set());
|
| 371 |
-
if (onBrush) onBrush([]);
|
| 372 |
-
return;
|
| 373 |
-
}
|
| 374 |
-
|
| 375 |
-
const [[x0, y0], [x1, y1]] = event.selection;
|
| 376 |
-
const selected = data.filter((d) => {
|
| 377 |
-
const x = xScale(d.x);
|
| 378 |
-
const y = yScale(d.y);
|
| 379 |
-
return x >= x0 && x <= x1 && y >= y0 && y <= y1;
|
| 380 |
-
});
|
| 381 |
-
|
| 382 |
-
setSelectedPoints(new Set(selected.map((d) => d.model_id)));
|
| 383 |
-
if (onBrush) onBrush(selected);
|
| 384 |
-
});
|
| 385 |
-
|
| 386 |
-
const brushG = g.append('g').attr('class', 'brush').call(brush);
|
| 387 |
-
|
| 388 |
-
// Update brush on zoom
|
| 389 |
-
svg.on('zoom.brush', () => {
|
| 390 |
-
brushG.call(brush);
|
| 391 |
-
});
|
| 392 |
-
|
| 393 |
-
// Add points with optimized transitions (reduced duration for better performance)
|
| 394 |
-
const points = g
|
| 395 |
-
.selectAll<SVGCircleElement, ModelPoint>('circle')
|
| 396 |
-
.data(sampledData, (d) => d.model_id)
|
| 397 |
-
.join(
|
| 398 |
-
(enter) =>
|
| 399 |
-
enter
|
| 400 |
-
.append('circle')
|
| 401 |
-
.attr('cx', (d) => xScale(d.x))
|
| 402 |
-
.attr('cy', (d) => yScale(d.y))
|
| 403 |
-
.attr('r', 0)
|
| 404 |
-
.attr('opacity', 0)
|
| 405 |
-
.call((enter) =>
|
| 406 |
-
enter
|
| 407 |
-
.transition()
|
| 408 |
-
.duration(sampledData.length > 10000 ? 200 : 300) // Faster transitions for large datasets
|
| 409 |
-
.ease(d3.easeCubicOut)
|
| 410 |
-
.attr('r', (d): number => {
|
| 411 |
-
if (useLogSize) {
|
| 412 |
-
// Custom function that takes ModelPoint
|
| 413 |
-
return (sizeScale as (d: ModelPoint) => number)(d);
|
| 414 |
-
} else {
|
| 415 |
-
// D3 scale that takes a number
|
| 416 |
-
if (sizeBy === 'downloads') return (sizeScale as ReturnType<typeof d3.scaleSqrt>)(d.downloads) as number;
|
| 417 |
-
if (sizeBy === 'likes') return (sizeScale as ReturnType<typeof d3.scaleSqrt>)(d.likes) as number;
|
| 418 |
-
return 5;
|
| 419 |
-
}
|
| 420 |
-
})
|
| 421 |
-
.attr('opacity', (d) => {
|
| 422 |
-
if (selectedPoints.has(d.model_id)) return 1;
|
| 423 |
-
if (hoveredPoint === d.model_id) return 1;
|
| 424 |
-
return 0.7;
|
| 425 |
-
})
|
| 426 |
-
),
|
| 427 |
-
(update) =>
|
| 428 |
-
update
|
| 429 |
-
.transition()
|
| 430 |
-
.duration(sampledData.length > 10000 ? 150 : 200) // Faster transitions for large datasets
|
| 431 |
-
.ease(d3.easeCubicOut)
|
| 432 |
-
.attr('cx', (d) => xScale(d.x))
|
| 433 |
-
.attr('cy', (d) => yScale(d.y))
|
| 434 |
-
.attr('r', (d): number => {
|
| 435 |
-
if (useLogSize) {
|
| 436 |
-
// Custom function that takes ModelPoint
|
| 437 |
-
return (sizeScale as (d: ModelPoint) => number)(d);
|
| 438 |
-
} else {
|
| 439 |
-
// D3 scale that takes a number
|
| 440 |
-
if (sizeBy === 'downloads') return (sizeScale as ReturnType<typeof d3.scaleSqrt>)(d.downloads) as number;
|
| 441 |
-
if (sizeBy === 'likes') return (sizeScale as ReturnType<typeof d3.scaleSqrt>)(d.likes) as number;
|
| 442 |
-
return 5;
|
| 443 |
-
}
|
| 444 |
-
})
|
| 445 |
-
.attr('opacity', (d) => {
|
| 446 |
-
if (selectedPoints.has(d.model_id)) return 1;
|
| 447 |
-
if (hoveredPoint === d.model_id) return 1;
|
| 448 |
-
return 0.7;
|
| 449 |
-
}),
|
| 450 |
-
(exit) =>
|
| 451 |
-
exit
|
| 452 |
-
.transition()
|
| 453 |
-
.duration(300)
|
| 454 |
-
.ease(d3.easeCubicIn)
|
| 455 |
-
.attr('r', 0)
|
| 456 |
-
.attr('opacity', 0)
|
| 457 |
-
.remove()
|
| 458 |
-
)
|
| 459 |
-
.attr('fill', (d) => {
|
| 460 |
-
if (colorBy === 'library_name') {
|
| 461 |
-
return (colorScale as d3.ScaleOrdinal<string, string>)(d.library_name || 'unknown');
|
| 462 |
-
}
|
| 463 |
-
if (colorBy === 'pipeline_tag') {
|
| 464 |
-
return (colorScale as d3.ScaleOrdinal<string, string>)(d.pipeline_tag || 'unknown');
|
| 465 |
-
}
|
| 466 |
-
if (colorBy === 'downloads') {
|
| 467 |
-
return (colorScale as d3.ScaleSequential<string, never>)(d.downloads);
|
| 468 |
-
}
|
| 469 |
-
return (colorScale as d3.ScaleSequential<string, never>)(d.likes);
|
| 470 |
-
})
|
| 471 |
-
.attr('stroke', (d) => {
|
| 472 |
-
if (selectedPoints.has(d.model_id)) return '#1a1a1a';
|
| 473 |
-
if (hoveredPoint === d.model_id) return '#4a4a4a';
|
| 474 |
-
return '#ffffff';
|
| 475 |
-
})
|
| 476 |
-
.attr('stroke-width', (d) => {
|
| 477 |
-
if (selectedPoints.has(d.model_id)) return 2.5;
|
| 478 |
-
if (hoveredPoint === d.model_id) return 2;
|
| 479 |
-
return 0.5;
|
| 480 |
-
})
|
| 481 |
-
.style('cursor', 'pointer')
|
| 482 |
-
.style('pointer-events', 'all')
|
| 483 |
-
.on('click', function (event, d) {
|
| 484 |
-
event.stopPropagation();
|
| 485 |
-
if (onPointClick) onPointClick(d);
|
| 486 |
-
})
|
| 487 |
-
.on('mouseover', function (event, d) {
|
| 488 |
-
setHoveredPoint(d.model_id);
|
| 489 |
-
const model = d as ModelPoint;
|
| 490 |
-
d3.select(this)
|
| 491 |
-
.transition()
|
| 492 |
-
.duration(150)
|
| 493 |
-
.attr('opacity', 1)
|
| 494 |
-
.attr('stroke-width', 2)
|
| 495 |
-
.attr('r', (): number => {
|
| 496 |
-
let baseSize: number;
|
| 497 |
-
if (useLogSize) {
|
| 498 |
-
// Custom function that takes ModelPoint
|
| 499 |
-
baseSize = (sizeScale as (d: ModelPoint) => number)(model);
|
| 500 |
-
} else {
|
| 501 |
-
// D3 scale that takes a number
|
| 502 |
-
baseSize = sizeBy === 'downloads'
|
| 503 |
-
? (sizeScale as ReturnType<typeof d3.scaleSqrt>)(model.downloads) as number
|
| 504 |
-
: sizeBy === 'likes'
|
| 505 |
-
? (sizeScale as ReturnType<typeof d3.scaleSqrt>)(model.likes) as number
|
| 506 |
-
: 5;
|
| 507 |
-
}
|
| 508 |
-
return baseSize * 1.3;
|
| 509 |
-
});
|
| 510 |
-
|
| 511 |
-
// Show enhanced tooltip
|
| 512 |
-
const [x, y] = [xScale(d.x), yScale(d.y)];
|
| 513 |
-
const tooltip = g
|
| 514 |
-
.append('g')
|
| 515 |
-
.attr('class', 'tooltip')
|
| 516 |
-
.attr('transform', `translate(${x + 15},${y - 15})`)
|
| 517 |
-
.style('pointer-events', 'none');
|
| 518 |
-
|
| 519 |
-
const tooltipBg = tooltip
|
| 520 |
-
.append('rect')
|
| 521 |
-
.attr('width', 220)
|
| 522 |
-
.attr('height', 100)
|
| 523 |
-
.attr('fill', 'rgba(26, 26, 26, 0.95)')
|
| 524 |
-
.attr('rx', 2)
|
| 525 |
-
.attr('opacity', 0)
|
| 526 |
-
.transition()
|
| 527 |
-
.duration(200)
|
| 528 |
-
.attr('opacity', 1);
|
| 529 |
-
|
| 530 |
-
tooltip
|
| 531 |
-
.append('text')
|
| 532 |
-
.attr('x', 12)
|
| 533 |
-
.attr('y', 20)
|
| 534 |
-
.attr('fill', 'white')
|
| 535 |
-
.attr('font-size', '13px')
|
| 536 |
-
.attr('font-weight', 'bold')
|
| 537 |
-
.text(d.model_id.length > 30 ? d.model_id.substring(0, 30) + '...' : d.model_id);
|
| 538 |
-
|
| 539 |
-
tooltip
|
| 540 |
-
.append('text')
|
| 541 |
-
.attr('x', 12)
|
| 542 |
-
.attr('y', 40)
|
| 543 |
-
.attr('fill', '#e0e0e0')
|
| 544 |
-
.attr('font-size', '11px')
|
| 545 |
-
.text(`Library: ${d.library_name || 'N/A'}`);
|
| 546 |
-
|
| 547 |
-
tooltip
|
| 548 |
-
.append('text')
|
| 549 |
-
.attr('x', 12)
|
| 550 |
-
.attr('y', 58)
|
| 551 |
-
.attr('fill', '#e0e0e0')
|
| 552 |
-
.attr('font-size', '11px')
|
| 553 |
-
.text(`Pipeline: ${d.pipeline_tag || 'N/A'}`);
|
| 554 |
-
|
| 555 |
-
tooltip
|
| 556 |
-
.append('text')
|
| 557 |
-
.attr('x', 12)
|
| 558 |
-
.attr('y', 76)
|
| 559 |
-
.attr('fill', '#d0d0d0')
|
| 560 |
-
.attr('font-size', '11px')
|
| 561 |
-
.text(`Downloads: ${d.downloads.toLocaleString()} | Likes: ${d.likes.toLocaleString()}`);
|
| 562 |
-
|
| 563 |
-
tooltip
|
| 564 |
-
.append('text')
|
| 565 |
-
.attr('x', 12)
|
| 566 |
-
.attr('y', 94)
|
| 567 |
-
.attr('fill', '#c0c0c0')
|
| 568 |
-
.attr('font-size', '10px')
|
| 569 |
-
.text('Click for details');
|
| 570 |
-
})
|
| 571 |
-
.on('mouseout', function (event, d) {
|
| 572 |
-
setHoveredPoint(null);
|
| 573 |
-
if (!selectedPoints.has(d.model_id)) {
|
| 574 |
-
const model = d as ModelPoint;
|
| 575 |
-
d3.select(this)
|
| 576 |
-
.transition()
|
| 577 |
-
.duration(150)
|
| 578 |
-
.attr('opacity', 0.7)
|
| 579 |
-
.attr('stroke-width', 0.5)
|
| 580 |
-
.attr('r', (): number => {
|
| 581 |
-
if (useLogSize) {
|
| 582 |
-
// Custom function that takes ModelPoint
|
| 583 |
-
return (sizeScale as (d: ModelPoint) => number)(model);
|
| 584 |
-
} else {
|
| 585 |
-
// D3 scale that takes a number
|
| 586 |
-
if (sizeBy === 'downloads') return (sizeScale as ReturnType<typeof d3.scaleSqrt>)(model.downloads) as number;
|
| 587 |
-
if (sizeBy === 'likes') return (sizeScale as ReturnType<typeof d3.scaleSqrt>)(model.likes) as number;
|
| 588 |
-
return 5;
|
| 589 |
-
}
|
| 590 |
-
});
|
| 591 |
-
}
|
| 592 |
-
g.selectAll('.tooltip').transition().duration(200).attr('opacity', 0).remove();
|
| 593 |
-
});
|
| 594 |
-
|
| 595 |
-
// Axes and grid will be updated automatically via the zoom transform
|
| 596 |
-
// The scales (xScale, yScale) are already reactive to transform changes
|
| 597 |
-
}, [
|
| 598 |
-
data,
|
| 599 |
-
xScale,
|
| 600 |
-
yScale,
|
| 601 |
-
colorScale,
|
| 602 |
-
sizeScale,
|
| 603 |
-
width,
|
| 604 |
-
height,
|
| 605 |
-
margin,
|
| 606 |
-
colorBy,
|
| 607 |
-
sizeBy,
|
| 608 |
-
selectedPoints,
|
| 609 |
-
hoveredPoint,
|
| 610 |
-
transform,
|
| 611 |
-
onPointClick,
|
| 612 |
-
onBrush,
|
| 613 |
-
resetZoom,
|
| 614 |
-
]);
|
| 615 |
-
|
| 616 |
-
return (
|
| 617 |
-
<div style={{ position: 'relative' }}>
|
| 618 |
-
<svg ref={svgRef} width={width} height={height} style={{ display: 'block' }} />
|
| 619 |
-
<div
|
| 620 |
-
style={{
|
| 621 |
-
position: 'absolute',
|
| 622 |
-
bottom: 10,
|
| 623 |
-
left: margin.left + 10,
|
| 624 |
-
fontSize: '11px',
|
| 625 |
-
color: '#6a6a6a',
|
| 626 |
-
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
| 627 |
-
padding: '4px 8px',
|
| 628 |
-
borderRadius: '2px',
|
| 629 |
-
border: '1px solid #d0d0d0',
|
| 630 |
-
fontFamily: "'Instrument Sans', sans-serif",
|
| 631 |
-
}}
|
| 632 |
-
>
|
| 633 |
-
<strong>Navigation:</strong> Scroll to zoom | Drag to pan | Click + drag to select
|
| 634 |
-
</div>
|
| 635 |
-
</div>
|
| 636 |
-
);
|
| 637 |
-
}
|
| 638 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/visualizations/HeatmapView.css
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
.heatmap-view {
|
| 2 |
-
padding: 1rem;
|
| 3 |
-
width: 100%;
|
| 4 |
-
height: 100%;
|
| 5 |
-
overflow: auto;
|
| 6 |
-
}
|
| 7 |
-
|
| 8 |
-
.heatmap-header {
|
| 9 |
-
display: flex;
|
| 10 |
-
justify-content: space-between;
|
| 11 |
-
align-items: center;
|
| 12 |
-
margin-bottom: 1rem;
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
.heatmap-header h3 {
|
| 16 |
-
margin: 0;
|
| 17 |
-
font-size: 1.25rem;
|
| 18 |
-
color: var(--text-primary, #1a1a1a);
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
.heatmap-stats {
|
| 22 |
-
font-size: 0.9rem;
|
| 23 |
-
color: var(--text-secondary, #666);
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.heatmap-svg {
|
| 27 |
-
border: 1px solid var(--border-color, #e0e0e0);
|
| 28 |
-
border-radius: 8px;
|
| 29 |
-
background: var(--bg-primary, #ffffff);
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
.heatmap-empty {
|
| 33 |
-
padding: 2rem;
|
| 34 |
-
text-align: center;
|
| 35 |
-
color: var(--text-secondary, #666);
|
| 36 |
-
}
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/visualizations/HeatmapView.tsx
DELETED
|
@@ -1,172 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Heatmap view showing density of models in latent space.
|
| 3 |
-
* Uses D3.js for rendering.
|
| 4 |
-
*/
|
| 5 |
-
import React, { useMemo, useRef, useEffect } from 'react';
|
| 6 |
-
import * as d3 from 'd3';
|
| 7 |
-
import { ModelPoint } from '../../types';
|
| 8 |
-
import './HeatmapView.css';
|
| 9 |
-
|
| 10 |
-
interface HeatmapViewProps {
|
| 11 |
-
data: ModelPoint[];
|
| 12 |
-
width?: number;
|
| 13 |
-
height?: number;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
export default function HeatmapView({ data, width = 800, height = 600 }: HeatmapViewProps) {
|
| 17 |
-
const svgRef = useRef<SVGSVGElement>(null);
|
| 18 |
-
|
| 19 |
-
const heatmapData = useMemo(() => {
|
| 20 |
-
if (data.length === 0) return null;
|
| 21 |
-
|
| 22 |
-
const gridSize = 50;
|
| 23 |
-
const grid: number[][] = Array(gridSize).fill(0).map(() => Array(gridSize).fill(0));
|
| 24 |
-
|
| 25 |
-
// Find bounds
|
| 26 |
-
const xValues = data.map(d => d.x);
|
| 27 |
-
const yValues = data.map(d => d.y);
|
| 28 |
-
const xMin = Math.min(...xValues);
|
| 29 |
-
const xMax = Math.max(...xValues);
|
| 30 |
-
const yMin = Math.min(...yValues);
|
| 31 |
-
const yMax = Math.max(...yValues);
|
| 32 |
-
|
| 33 |
-
// Populate grid
|
| 34 |
-
data.forEach(model => {
|
| 35 |
-
const x = Math.floor(((model.x - xMin) / (xMax - xMin)) * (gridSize - 1));
|
| 36 |
-
const y = Math.floor(((model.y - yMin) / (yMax - yMin)) * (gridSize - 1));
|
| 37 |
-
if (x >= 0 && x < gridSize && y >= 0 && y < gridSize) {
|
| 38 |
-
grid[y][x]++;
|
| 39 |
-
}
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
return { grid, xMin, xMax, yMin, yMax };
|
| 43 |
-
}, [data]);
|
| 44 |
-
|
| 45 |
-
useEffect(() => {
|
| 46 |
-
if (!heatmapData || !svgRef.current) return;
|
| 47 |
-
|
| 48 |
-
const svg = d3.select(svgRef.current);
|
| 49 |
-
svg.selectAll('*').remove();
|
| 50 |
-
|
| 51 |
-
const margin = { top: 20, right: 20, bottom: 40, left: 40 };
|
| 52 |
-
const innerWidth = width - margin.left - margin.right;
|
| 53 |
-
const innerHeight = height - margin.top - margin.bottom;
|
| 54 |
-
|
| 55 |
-
const g = svg.append('g')
|
| 56 |
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 57 |
-
|
| 58 |
-
const gridSize = heatmapData.grid.length;
|
| 59 |
-
const cellWidth = innerWidth / gridSize;
|
| 60 |
-
const cellHeight = innerHeight / gridSize;
|
| 61 |
-
|
| 62 |
-
const maxValue = Math.max(...heatmapData.grid.flat());
|
| 63 |
-
const colorScale = d3.scaleSequential(d3.interpolateViridis)
|
| 64 |
-
.domain([0, maxValue]);
|
| 65 |
-
|
| 66 |
-
// Create heatmap cells
|
| 67 |
-
heatmapData.grid.forEach((row, y) => {
|
| 68 |
-
row.forEach((value, x) => {
|
| 69 |
-
g.append('rect')
|
| 70 |
-
.attr('x', x * cellWidth)
|
| 71 |
-
.attr('y', y * cellHeight)
|
| 72 |
-
.attr('width', cellWidth)
|
| 73 |
-
.attr('height', cellHeight)
|
| 74 |
-
.attr('fill', colorScale(value))
|
| 75 |
-
.attr('stroke', 'none')
|
| 76 |
-
.append('title')
|
| 77 |
-
.text(`Density: ${value} models`);
|
| 78 |
-
});
|
| 79 |
-
});
|
| 80 |
-
|
| 81 |
-
// Add axes
|
| 82 |
-
const xScale = d3.scaleLinear()
|
| 83 |
-
.domain([heatmapData.xMin, heatmapData.xMax])
|
| 84 |
-
.range([0, innerWidth]);
|
| 85 |
-
|
| 86 |
-
const yScale = d3.scaleLinear()
|
| 87 |
-
.domain([heatmapData.yMin, heatmapData.yMax])
|
| 88 |
-
.range([innerHeight, 0]);
|
| 89 |
-
|
| 90 |
-
const xAxis = d3.axisBottom(xScale);
|
| 91 |
-
const yAxis = d3.axisLeft(yScale);
|
| 92 |
-
|
| 93 |
-
g.append('g')
|
| 94 |
-
.attr('transform', `translate(0,${innerHeight})`)
|
| 95 |
-
.call(xAxis)
|
| 96 |
-
.append('text')
|
| 97 |
-
.attr('x', innerWidth / 2)
|
| 98 |
-
.attr('y', 35)
|
| 99 |
-
.attr('fill', 'var(--text-primary, #1a1a1a)')
|
| 100 |
-
.style('text-anchor', 'middle')
|
| 101 |
-
.text('X Coordinate');
|
| 102 |
-
|
| 103 |
-
g.append('g')
|
| 104 |
-
.call(yAxis)
|
| 105 |
-
.append('text')
|
| 106 |
-
.attr('transform', 'rotate(-90)')
|
| 107 |
-
.attr('y', -30)
|
| 108 |
-
.attr('x', -innerHeight / 2)
|
| 109 |
-
.attr('fill', 'var(--text-primary, #1a1a1a)')
|
| 110 |
-
.style('text-anchor', 'middle')
|
| 111 |
-
.text('Y Coordinate');
|
| 112 |
-
|
| 113 |
-
// Add color legend
|
| 114 |
-
const legendWidth = 20;
|
| 115 |
-
const legendHeight = 200;
|
| 116 |
-
const legendX = innerWidth + 10;
|
| 117 |
-
|
| 118 |
-
const legendScale = d3.scaleLinear()
|
| 119 |
-
.domain([0, maxValue])
|
| 120 |
-
.range([legendHeight, 0]);
|
| 121 |
-
|
| 122 |
-
const legendAxis = d3.axisRight(legendScale).ticks(5);
|
| 123 |
-
|
| 124 |
-
const legendG = g.append('g')
|
| 125 |
-
.attr('transform', `translate(${legendX}, 0)`);
|
| 126 |
-
|
| 127 |
-
const defs = svg.append('defs');
|
| 128 |
-
const gradient = defs.append('linearGradient')
|
| 129 |
-
.attr('id', 'heatmap-gradient')
|
| 130 |
-
.attr('x1', '0%')
|
| 131 |
-
.attr('x2', '0%')
|
| 132 |
-
.attr('y1', '0%')
|
| 133 |
-
.attr('y2', '100%');
|
| 134 |
-
|
| 135 |
-
const numStops = 10;
|
| 136 |
-
for (let i = 0; i <= numStops; i++) {
|
| 137 |
-
const value = (i / numStops) * maxValue;
|
| 138 |
-
gradient.append('stop')
|
| 139 |
-
.attr('offset', `${(i / numStops) * 100}%`)
|
| 140 |
-
.attr('stop-color', colorScale(value));
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
legendG.append('rect')
|
| 144 |
-
.attr('width', legendWidth)
|
| 145 |
-
.attr('height', legendHeight)
|
| 146 |
-
.attr('fill', 'url(#heatmap-gradient)');
|
| 147 |
-
|
| 148 |
-
legendG.append('g')
|
| 149 |
-
.attr('transform', `translate(${legendWidth}, 0)`)
|
| 150 |
-
.call(legendAxis);
|
| 151 |
-
|
| 152 |
-
}, [heatmapData, width, height]);
|
| 153 |
-
|
| 154 |
-
if (!heatmapData) {
|
| 155 |
-
return (
|
| 156 |
-
<div className="heatmap-view">
|
| 157 |
-
<div className="heatmap-empty">No data to display</div>
|
| 158 |
-
</div>
|
| 159 |
-
);
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
return (
|
| 163 |
-
<div className="heatmap-view">
|
| 164 |
-
<div className="heatmap-header">
|
| 165 |
-
<h3>Model Density Heatmap</h3>
|
| 166 |
-
<div className="heatmap-stats">Total Models: {data.length.toLocaleString()}</div>
|
| 167 |
-
</div>
|
| 168 |
-
<svg ref={svgRef} width={width} height={height} className="heatmap-svg" />
|
| 169 |
-
</div>
|
| 170 |
-
);
|
| 171 |
-
}
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|