midah commited on
Commit
637183f
·
1 Parent(s): c9c8026

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
Files changed (50) hide show
  1. Dockerfile +1 -1
  2. README.md +30 -0
  3. auto_start.sh +66 -0
  4. auto_start_output.log +8 -0
  5. backend.log +40 -2
  6. backend/api/main.py +127 -88
  7. backend/config/requirements.txt +7 -0
  8. backend/core/config.py +24 -3
  9. backend/scripts/precompute_data.py +246 -0
  10. backend/scripts/precompute_fast.py +276 -0
  11. backend/utils/cache.py +153 -0
  12. backend/utils/data_loader.py +107 -10
  13. backend/utils/precomputed_loader.py +174 -0
  14. backend/utils/response_encoder.py +107 -0
  15. backend_full.log +119 -0
  16. backend_full_processing.log +5 -0
  17. deploy/backend-Procfile +1 -1
  18. deploy/fly.toml +33 -0
  19. deploy/nginx.conf +158 -0
  20. deploy/railway.json +2 -2
  21. deploy/render.yaml +5 -3
  22. deploy_railway.sh +50 -0
  23. docker-compose.yml +73 -0
  24. frontend/package-lock.json +127 -3
  25. frontend/package.json +10 -0
  26. frontend/public/index.html +0 -3
  27. frontend/src/App.css +767 -761
  28. frontend/src/App.tsx +453 -555
  29. frontend/src/components/controls/ClusterFilter.css +5 -5
  30. frontend/src/components/controls/RenderingStyleSelector.css +1 -1
  31. frontend/src/components/controls/ThemeToggle.tsx +1 -1
  32. frontend/src/components/controls/VisualizationModeButtons.css +1 -1
  33. frontend/src/components/controls/VisualizationModeButtons.tsx +3 -6
  34. frontend/src/components/layout/SearchBar.css +9 -9
  35. frontend/src/components/modals/FileTree.css +13 -13
  36. frontend/src/components/modals/FileTree.tsx +16 -16
  37. frontend/src/components/modals/ModelModal.css +24 -24
  38. frontend/src/components/modals/ModelModal.tsx +3 -3
  39. frontend/src/components/ui/ColorLegend.css +3 -3
  40. frontend/src/components/ui/ErrorBoundary.tsx +3 -3
  41. frontend/src/components/ui/LiveModelCount.css +5 -5
  42. frontend/src/components/ui/LiveModelCount.tsx +2 -2
  43. frontend/src/components/ui/ModelCountTracker.css +9 -9
  44. frontend/src/components/ui/ModelTooltip.tsx +1 -1
  45. frontend/src/components/ui/VirtualSearchResults.tsx +80 -0
  46. frontend/src/components/visualizations/DistanceHeatmap.tsx +2 -2
  47. frontend/src/components/visualizations/DistributionView.css +3 -3
  48. frontend/src/components/visualizations/EnhancedScatterPlot.tsx +0 -638
  49. frontend/src/components/visualizations/HeatmapView.css +0 -37
  50. 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 ["python", "api.py"]
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 [24332] using WatchFiles
4
- INFO: Started server process [24386]
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
- # All variables are accessed via deps module, no need for global declarations
 
 
 
 
 
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
- sample_size = settings.get_sample_size()
122
- if sample_size:
123
- logger.info(f"Loading limited dataset: {sample_size} models (SAMPLE_SIZE={sample_size})")
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
- # Initialize graph embedder and generate graph embeddings (optional, lazy-loaded)
152
- if settings.USE_GRAPH_EMBEDDINGS:
153
- try:
154
- deps.graph_embedder = GraphEmbedder()
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
- # Initialize reducer for graph-aware embeddings if available
229
- if deps.combined_embeddings is not None:
230
- reducer_graph = DimensionReducer(method="umap", n_components=3)
231
-
232
- if os.path.exists(reduced_cache_umap_graph) and os.path.exists(reducer_cache_umap_graph):
233
- try:
234
- with open(reduced_cache_umap_graph, 'rb') as f:
235
- deps.reduced_embeddings_graph = pickle.load(f)
236
- reducer_graph.load_reducer(reducer_cache_umap_graph)
237
- except (IOError, pickle.UnpicklingError, EOFError) as e:
238
- logger.warning(f"Failed to load cached graph-aware reduced embeddings: {e}")
239
- deps.reduced_embeddings_graph = None
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
- return {
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
- sample_size_val = int(sample_size_env)
19
- return sample_size_val if sample_size_val > 0 else None
 
 
 
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") -> pd.DataFrame:
 
18
  """
19
- Load dataset from Hugging Face Hub.
20
 
21
  Args:
22
- sample_size: If provided, randomly sample this many rows
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
- if sample_size and len(dataset) > sample_size:
31
- dataset = dataset.shuffle(seed=42).select(range(sample_size))
32
-
33
- self.df = dataset.to_pandas()
34
 
35
- return self.df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: python backend/api.py
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 && pip install -r backend/requirements.txt"
6
  },
7
  "deploy": {
8
- "startCommand": "cd backend && python api.py",
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 backend/requirements.txt
6
- startCommand: cd backend && python api.py
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": "7.1.1",
11130
- "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
11131
- "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
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
- .App {
2
- font-family: 'Instrument Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
3
- -webkit-font-smoothing: antialiased;
4
- -moz-osx-font-smoothing: grayscale;
5
- letter-spacing: normal;
6
- font-variant-ligatures: common-ligatures;
7
- }
8
-
9
- .App-header {
10
- background: #2d2d2d;
11
- color: #ffffff;
12
- padding: 2.5rem 2rem;
13
- text-align: center;
14
- border-bottom: 1px solid #404040;
15
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
16
- position: relative;
17
- }
18
-
19
-
20
- .App-header h1 {
21
- margin: 0 0 1rem 0;
22
- font-size: 2rem;
23
- font-weight: 600;
24
- letter-spacing: -0.01em;
25
- line-height: 1.3;
26
- color: #ffffff;
27
- text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
28
- }
29
-
30
- .App-header p {
31
- margin: 0;
32
- opacity: 0.95;
33
- line-height: 1.6;
34
- position: relative;
35
- z-index: 1;
36
- }
37
-
38
- .App-header a {
39
- color: #64b5f6;
40
- text-decoration: none;
41
- transition: all 0.2s;
42
- font-weight: 500;
43
- border-bottom: 1px solid transparent;
44
- }
45
-
46
- .App-header a:hover {
47
- color: #90caf9;
48
- border-bottom-color: #90caf9;
49
- }
50
-
51
- .stats {
52
- display: flex;
53
- gap: 1.5rem;
54
- justify-content: center;
55
- margin-top: 2rem;
56
- font-size: 0.95rem;
57
- flex-wrap: wrap;
58
- position: relative;
59
- z-index: 1;
60
- }
61
-
62
- .stats span {
63
- padding: 0.625rem 1.25rem;
64
- background: rgba(255, 255, 255, 0.1);
65
- border-radius: 6px;
66
- border: 1px solid rgba(255, 255, 255, 0.2);
67
- transition: all 0.2s ease;
68
- font-weight: 500;
69
- }
70
-
71
- .stats span:hover {
72
- background: rgba(255, 255, 255, 0.15);
73
- transform: translateY(-1px);
74
- }
75
-
76
- .main-content {
77
- display: flex;
78
- height: calc(100vh - 200px);
79
- }
80
-
81
- .sidebar {
82
- width: 340px;
83
- padding: 1.5rem;
84
- background: #fafafa;
85
- overflow-y: auto;
86
- border-right: 1px solid #e0e0e0;
87
- }
88
-
89
- .sidebar h2 {
90
- margin-top: 0;
91
- font-size: 1.25rem;
92
- font-weight: 600;
93
- color: #1a1a1a;
94
- letter-spacing: -0.01em;
95
- }
96
-
97
- .sidebar h3 {
98
- font-size: 0.9rem;
99
- font-weight: 600;
100
- color: #2d2d2d;
101
- margin: 0 0 0.875rem 0;
102
- letter-spacing: -0.01em;
103
- }
104
-
105
- .sidebar label {
106
- display: block;
107
- margin-bottom: 1.25rem;
108
- font-weight: 500;
109
- }
110
-
111
- .sidebar input[type="text"],
112
- .sidebar select {
113
- width: 100%;
114
- padding: 0.75rem 1rem;
115
- margin-top: 0.5rem;
116
- border: 2px solid #e8e8e8;
117
- border-radius: 6px;
118
- font-size: 0.9rem;
119
- font-family: 'Instrument Sans', sans-serif;
120
- background: #ffffff;
121
- color: #1a1a1a;
122
- transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
123
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
124
- }
125
-
126
- .sidebar input[type="text"]:hover,
127
- .sidebar select:hover {
128
- border-color: #d0d0d0;
129
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
130
- }
131
-
132
- .sidebar input[type="text"]:focus,
133
- .sidebar select:focus {
134
- outline: none;
135
- border-color: #4a4a4a;
136
- box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
137
- }
138
-
139
- .sidebar input[type="range"] {
140
- width: 100%;
141
- margin-top: 0.75rem;
142
- height: 6px;
143
- -webkit-appearance: none;
144
- appearance: none;
145
- background: linear-gradient(to right, #e8e8e8 0%, #e8e8e8 100%);
146
- border-radius: 3px;
147
- outline: none;
148
- cursor: pointer;
149
- transition: all 0.3s ease;
150
- }
151
-
152
- .sidebar input[type="range"]:hover {
153
- background: linear-gradient(to right, #d8d8d8 0%, #d8d8d8 100%);
154
- }
155
-
156
- .sidebar input[type="range"]::-webkit-slider-thumb {
157
- -webkit-appearance: none;
158
- appearance: none;
159
- width: 18px;
160
- height: 18px;
161
- border-radius: 50%;
162
- background: #4a4a4a;
163
- cursor: pointer;
164
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
165
- transition: all 0.2s ease;
166
- border: 2px solid #ffffff;
167
- }
168
-
169
- .sidebar input[type="range"]::-webkit-slider-thumb:hover {
170
- background: #2d2d2d;
171
- transform: scale(1.1);
172
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
173
- }
174
-
175
- .sidebar input[type="range"]::-webkit-slider-thumb:active {
176
- transform: scale(1.1);
177
- }
178
-
179
- .sidebar input[type="range"]::-moz-range-thumb {
180
- width: 18px;
181
- height: 18px;
182
- border-radius: 50%;
183
- background: #4a4a4a;
184
- cursor: pointer;
185
- border: 2px solid #ffffff;
186
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
187
- transition: all 0.2s ease;
188
- }
189
-
190
- .sidebar input[type="range"]::-moz-range-thumb:hover {
191
- background: #2d2d2d;
192
- transform: scale(1.1);
193
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
194
- }
195
-
196
- .sidebar input[type="range"]::-moz-range-thumb:active {
197
- transform: scale(1.1);
198
- }
199
-
200
- .sidebar input[type="range"]::-webkit-slider-runnable-track {
201
- width: 100%;
202
- height: 6px;
203
- background: transparent;
204
- border-radius: 3px;
205
- }
206
-
207
- .sidebar input[type="range"]::-moz-range-track {
208
- width: 100%;
209
- height: 6px;
210
- background: transparent;
211
- border-radius: 3px;
212
- }
213
-
214
- .sidebar label {
215
- transition: all 0.2s ease;
216
- }
217
-
218
- .sidebar-section {
219
- background: #ffffff;
220
- border-radius: 6px;
221
- padding: 1.25rem;
222
- margin-bottom: 1rem;
223
- border: 1px solid #e0e0e0;
224
- transition: all 0.2s ease;
225
- }
226
-
227
- .sidebar-section:hover {
228
- border-color: #d0d0d0;
229
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
230
- }
231
-
232
- .filter-chip {
233
- display: inline-flex;
234
- align-items: center;
235
- gap: 0.5rem;
236
- padding: 0.4rem 0.75rem;
237
- background: #e3f2fd;
238
- color: #1976d2;
239
- border-radius: 16px;
240
- font-size: 0.8rem;
241
- font-weight: 500;
242
- border: 1px solid #90caf9;
243
- cursor: pointer;
244
- transition: all 0.2s;
245
- }
246
-
247
- .filter-chip:hover {
248
- background: #bbdefb;
249
- transform: translateY(-1px);
250
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
251
- }
252
-
253
- .filter-chip.active {
254
- background: #1976d2;
255
- color: white;
256
- border-color: #1976d2;
257
- }
258
-
259
- .filter-chip .remove {
260
- cursor: pointer;
261
- font-weight: bold;
262
- opacity: 0.7;
263
- transition: opacity 0.2s;
264
- }
265
-
266
- .filter-chip .remove:hover {
267
- opacity: 1;
268
- }
269
-
270
- .visualization {
271
- flex: 1;
272
- padding: 1.5rem;
273
- display: flex;
274
- align-items: center;
275
- justify-content: center;
276
- background: #ffffff;
277
- overflow: auto;
278
- transition: all 0.3s ease;
279
- position: relative;
280
- }
281
-
282
- .visualization > * {
283
- transition: opacity 0.3s ease, transform 0.3s ease;
284
- }
285
-
286
- .visualization svg {
287
- display: block;
288
- background: #ffffff;
289
- }
290
-
291
- .loading,
292
- .error,
293
- .empty {
294
- text-align: center;
295
- padding: 4rem 2.5rem;
296
- font-size: 1.1rem;
297
- display: flex;
298
- flex-direction: column;
299
- align-items: center;
300
- gap: 1.5rem;
301
- border-radius: 12px;
302
- animation: fadeIn 0.4s ease-in-out;
303
- }
304
-
305
- @keyframes fadeIn {
306
- from { opacity: 0; transform: translateY(10px); }
307
- to { opacity: 1; transform: translateY(0); }
308
- }
309
-
310
- .loading {
311
- color: #2d2d2d;
312
- font-weight: 600;
313
- background: #f5f5f5;
314
- border: 1px solid #d0d0d0;
315
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
316
- }
317
-
318
- .loading::after {
319
- content: '';
320
- width: 40px;
321
- height: 40px;
322
- border: 4px solid #e0e0e0;
323
- border-top-color: #4a4a4a;
324
- border-radius: 50%;
325
- animation: spin 0.8s linear infinite;
326
- }
327
-
328
- @keyframes spin {
329
- to { transform: rotate(360deg); }
330
- }
331
-
332
- .error {
333
- color: #d32f2f;
334
- background: #ffebee;
335
- border-radius: 8px;
336
- border: 1px solid #ffcdd2;
337
- max-width: 550px;
338
- margin: 0 auto;
339
- font-weight: 500;
340
- }
341
-
342
- .empty {
343
- color: #6a6a6a;
344
- background: #f5f5f5;
345
- border-radius: 8px;
346
- border: 1px solid #e0e0e0;
347
- max-width: 550px;
348
- margin: 0 auto;
349
- font-weight: 500;
350
- }
351
-
352
- .btn {
353
- padding: 0.625rem 1.25rem;
354
- border-radius: 4px;
355
- border: none;
356
- font-size: 0.9rem;
357
- font-weight: 600;
358
- cursor: pointer;
359
- transition: all 0.2s ease;
360
- font-family: 'Instrument Sans', sans-serif;
361
- display: inline-flex;
362
- align-items: center;
363
- justify-content: center;
364
- gap: 0.5rem;
365
- }
366
-
367
-
368
- .btn-primary {
369
- background: #2d2d2d;
370
- color: white;
371
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
372
- }
373
-
374
- .btn-primary:hover {
375
- background: #1a1a1a;
376
- transform: translateY(-1px);
377
- box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
378
- }
379
-
380
- .btn-secondary {
381
- background: #f5f5f5;
382
- color: #2d2d2d;
383
- border: 1px solid #d0d0d0;
384
- }
385
-
386
- .btn-secondary:hover {
387
- background: #e8e8e8;
388
- border-color: #b0b0b0;
389
- }
390
-
391
- .btn-small {
392
- padding: 0.4rem 0.875rem;
393
- font-size: 0.85rem;
394
- border-radius: 5px;
395
- }
396
-
397
- .collapsible-section {
398
- margin-bottom: 1rem;
399
- }
400
-
401
- .collapsible-header {
402
- display: flex;
403
- justify-content: space-between;
404
- align-items: center;
405
- cursor: pointer;
406
- padding: 0.75rem;
407
- background: #f5f5f5;
408
- border-radius: 6px;
409
- transition: all 0.2s;
410
- user-select: none;
411
- }
412
-
413
- .collapsible-header:hover {
414
- background: #eeeeee;
415
- }
416
-
417
- .collapsible-header h3 {
418
- margin: 0;
419
- font-size: 0.9rem;
420
- font-weight: 600;
421
- color: #2a2a2a;
422
- }
423
-
424
- .collapsible-content {
425
- padding: 1rem 0.75rem;
426
- animation: slideDown 0.2s ease-out;
427
- }
428
-
429
- @keyframes slideDown {
430
- from {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  opacity: 0;
432
- transform: translateY(-10px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  }
434
- to {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  opacity: 1;
436
- transform: translateY(0);
437
- }
438
- }
439
-
440
- .tooltip {
441
- position: relative;
442
- cursor: help;
443
- }
444
-
445
- .tooltip::after {
446
- content: attr(data-tooltip);
447
- position: absolute;
448
- bottom: 100%;
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, lazy, Suspense } from 'react';
2
  // Visualizations
3
- import EnhancedScatterPlot from './components/visualizations/EnhancedScatterPlot';
 
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, { Cluster } from './components/controls/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
- const response = await requestManager.fetch(url, {}, cacheKey);
182
- if (!response.ok) throw new Error('Failed to fetch similar models');
183
- const result = await response.json();
184
- models = result.models || [];
185
- count = models.length;
 
 
 
 
 
 
 
 
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', '500000');
 
 
201
 
202
  const url = `${API_BASE}/api/models?${params}`;
203
- const response = await requestManager.fetch(url, {}, cacheKey);
204
- if (!response.ok) throw new Error('Failed to fetch models');
205
- const result = await response.json();
206
-
207
- if (Array.isArray(result)) {
208
- models = result;
209
- count = models.length;
210
- setEmbeddingType('text-only');
211
- } else {
212
- models = result.models || [];
213
- count = result.filtered_count ?? models.length;
214
- setEmbeddingType(result.embedding_type || 'text-only');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <h1>Anatomy of a Machine Learning Ecosystem: 2 Million Models on Hugging Face</h1>
455
- <p style={{ maxWidth: '900px', margin: '0 auto', lineHeight: '1.6' }}>
456
- Many have observed that the development and deployment of generative machine learning (ML) and artificial intelligence (AI) models follow a distinctive pattern in which pre-trained models are adapted and fine-tuned for specific downstream tasks. However, there is limited empirical work that examines the structure of these interactions. This paper analyzes 1.86 million models on Hugging Face, a leading peer production platform for model development. Our study of model family trees reveals sprawling fine-tuning lineages that vary widely in size and structure. Using an evolutionary biology lens, we measure genetic similarity and mutation of traits over model families.
457
- {' '}
458
- <a
459
- href="https://arxiv.org/abs/2508.06811"
460
- target="_blank"
461
- rel="noopener noreferrer"
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: '12px',
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: '12px',
557
  fontWeight: '600'
558
  }}>
559
  Graph
@@ -575,17 +650,15 @@ function App() {
575
 
576
  {/* Search Section */}
577
  <div className="sidebar-section">
578
- <h3>Search Models</h3>
579
  <input
580
  type="text"
581
  value={searchQuery}
582
  onChange={(e) => setSearchQuery(e.target.value)}
583
- placeholder="Search by model ID, tags, or keywords..."
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
- <div className="sidebar-section">
645
- <h3>License Filter</h3>
646
- <div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '0.5rem' }}>
 
 
647
  {Object.entries(stats.licenses as Record<string, number>)
648
- .sort((a, b) => b[1] - a[1]) // Sort by count descending
649
- .slice(0, 20) // Show top 20 licenses
650
  .map(([license, count]) => (
651
  <label
652
  key={license}
653
  style={{
654
  display: 'flex',
655
  alignItems: 'center',
656
- gap: '0.5rem',
657
- marginBottom: '0.5rem',
658
  cursor: 'pointer',
659
- fontSize: '0.9rem'
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.75rem', color: '#666' }}>({Number(count).toLocaleString()})</span>
677
  </label>
678
  ))}
679
  </div>
680
- {Object.keys(stats.licenses).length > 20 && (
681
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.5rem' }}>
682
- Showing top 20 licenses
683
- </div>
684
- )}
685
- </div>
686
  )}
687
 
688
- {/* Discovery */}
689
  <div className="sidebar-section">
690
- <h3>Discovery</h3>
691
- <RandomModelButton
692
- data={data}
693
- onSelect={(model: ModelPoint) => {
694
- setSelectedModel(model);
695
- setIsModalOpen(true);
696
- }}
697
- disabled={loading || data.length === 0}
698
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  </div>
700
 
701
- {/* Visualization Options */}
702
  <div className="sidebar-section">
703
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
704
- <h3 style={{ margin: 0 }}>Visualization Options</h3>
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.5rem', borderRadius: '4px', border: '1px solid #d0d0d0' }}
 
714
  >
715
- <option value="3d">3D Latent Space</option>
716
- <option value="scatter">2D Latent Space</option>
717
- <option value="network">Network Graph</option>
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
- {/* Rendering Style Selector for 3D View */}
733
- {viewMode === '3d' && (
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 Encoding</span>
767
  <select
768
  value={colorBy}
769
  onChange={(e) => setColorBy(e.target.value as ColorByOption)}
770
- style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid #d0d0d0' }}
 
771
  >
772
- <option value="library_name">Library (e.g., transformers, diffusers)</option>
773
- <option value="pipeline_tag">Pipeline/Task Type</option>
774
- <option value="cluster_id">Cluster (semantic groups)</option>
775
- <option value="family_depth">Family Tree Depth</option>
776
- <option value="downloads">Download Count</option>
777
- <option value="likes">Like Count</option>
778
- <option value="trending_score">Trending Score</option>
779
- <option value="licenses">License Type</option>
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 Selector (for continuous scales) */}
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.5rem', borderRadius: '4px', border: '1px solid #d0d0d0' }}
796
  >
797
- <option value="viridis">Viridis (blue to yellow)</option>
798
- <option value="plasma">Plasma (purple to yellow)</option>
799
- <option value="inferno">Inferno (black to yellow)</option>
800
- <option value="magma">Magma (black to pink)</option>
801
- <option value="coolwarm">Cool-Warm (blue to red)</option>
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 Encoding</span>
819
  <select
820
  value={sizeBy}
821
  onChange={(e) => setSizeBy(e.target.value as SizeByOption)}
822
- style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid #d0d0d0' }}
 
823
  >
824
- <option value="downloads">Downloads (larger = more popular)</option>
825
- <option value="likes">Likes (larger = more liked)</option>
826
- <option value="trendingScore">Trending Score</option>
827
- <option value="none">Uniform Size</option>
828
  </select>
829
  </label>
830
 
831
- <div className="sidebar-section" style={{ background: '#f5f5f5', borderColor: '#d0d0d0', marginBottom: '1rem', padding: '0.75rem', borderRadius: '4px', border: '1px solid' }}>
832
- <label style={{ display: 'block', marginBottom: '0' }}>
833
- <span style={{ fontWeight: '600', display: 'block', marginBottom: '0.5rem', color: '#2d2d2d' }}>
834
- Projection Method
835
- </span>
836
  <select
837
  value={projectionMethod}
838
  onChange={(e) => setProjectionMethod(e.target.value as 'umap' | 'tsne')}
839
- style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid #d0d0d0', fontWeight: '500' }}
 
840
  >
841
- <option value="umap">UMAP (better global structure)</option>
842
- <option value="tsne">t-SNE (better local clusters)</option>
843
  </select>
844
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.5rem', lineHeight: '1.4' }}>
845
- <strong>UMAP:</strong> Preserves global structure, better for exploring relationships<br/>
846
- <strong>t-SNE:</strong> Emphasizes local clusters, better for finding groups
847
- </div>
848
- </label>
849
- </div>
850
  </div>
851
 
852
- {/* View Modes */}
853
- <div className="sidebar-section">
854
- <h3>View Modes</h3>
 
 
855
 
856
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
857
- <input
858
- type="checkbox"
859
- checked={baseModelsOnly}
860
- onChange={(e) => setBaseModelsOnly(e.target.checked)}
861
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
862
- />
863
- <div>
864
- <span style={{ fontWeight: '500' }}>Base Models Only</span>
865
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
866
- Show only root models (no parent). Click any model to see its family tree.
867
- </div>
868
- </div>
869
- </label>
870
 
871
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
872
- <input
873
- type="checkbox"
874
- checked={semanticSimilarityMode}
875
- onChange={(e) => {
876
- setSemanticSimilarityMode(e.target.checked);
877
- if (!e.target.checked) {
878
- setSemanticQueryModel(null);
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
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
892
- <input
893
- type="checkbox"
894
- checked={useGraphEmbeddings}
895
- onChange={(e) => setUseGraphEmbeddings(e.target.checked)}
896
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
897
- />
898
- <div>
899
- <span style={{ fontWeight: '500' }}>Graph-Aware Embeddings</span>
900
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
901
- Use embeddings that respect family tree structure. Models in the same family will be closer together.
902
- </div>
903
- </div>
904
- </label>
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: 'white', borderRadius: '4px', border: '1px solid #d0d0d0' }}>
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="e.g., bert-base-uncased"
939
- style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid #d0d0d0' }}
 
940
  />
941
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.5rem' }}>
942
- {selectedModel && (
943
- <button
944
- onClick={() => setSemanticQueryModel(selectedModel.model_id)}
945
- style={{
946
- padding: '0.25rem 0.5rem',
947
- background: '#4a90e2',
948
- color: 'white',
949
- border: 'none',
950
- borderRadius: '4px',
951
- cursor: 'pointer',
952
- fontSize: '0.75rem'
953
- }}
954
- >
955
- Use Selected Model
956
- </button>
957
- )}
958
- <div style={{ marginTop: '0.5rem' }}>
959
- Enter a model ID or click a model in the visualization, then click "Use Selected Model"
960
- </div>
961
- </div>
962
  </div>
963
  )}
964
- </div>
965
 
966
  {/* Structural Visualization Options */}
967
- {viewMode === '3d' && (
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: '4px', border: '1px solid #d0d0d0' }}>
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: '4px', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
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
- {/* Quick Filters */}
1039
- <div className="sidebar-section">
1040
- <h3>Quick Actions</h3>
1041
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
1042
- <button
1043
- onClick={() => {
1044
- setMinDownloads(stats ? Math.ceil(stats.avg_downloads) : 100);
1045
- setMinLikes(0);
1046
- }}
1047
- className="btn btn-small"
1048
- style={{
1049
- background: '#e3f2fd',
1050
- color: '#1976d2',
1051
- border: '1px solid #90caf9'
1052
- }}
1053
- >
1054
- Above Avg Downloads
1055
- </button>
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
- Clear Path
1159
- </button>
1160
- </div>
1161
- )}
1162
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: '2px', border: '1px solid #d0d0d0' }}
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: '200px',
1186
- overflowY: 'auto',
1187
  zIndex: 1000,
1188
  boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
1189
  }}>
1190
- {searchResults.map((result) => (
1191
- <div
1192
- key={result.model_id}
1193
- onClick={() => {
1194
- loadFamilyTree(result.model_id);
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: '2px', border: '1px solid #d0d0d0' }}>
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: '2px', border: '1px solid #d0d0d0' }}>
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: '2px', fontSize: '0.85rem' }}>
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: '2px', border: '1px solid #d0d0d0' }}>
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: '2px' }}>
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: '4px' }}>
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
- <EnhancedScatterPlot
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: 4px;
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: 4px;
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: 4px;
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: 3px;
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: 2px;
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: 4px;
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: 6px;
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: '3d', label: '3D Embedding', icon: '🎯', description: 'Interactive 3D exploration' },
18
- { value: 'scatter', label: '2D Scatter', icon: '📊', description: '2D projection view' },
19
- { value: 'network', label: 'Network', icon: '🕸️', description: 'Network graph view' },
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: 8px;
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: 'Instrument Sans', sans-serif;
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: 8px;
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: 'Instrument Sans', sans-serif;
109
  }
110
 
111
  .result-org {
@@ -113,7 +113,7 @@
113
  color: #666;
114
  background: #f0f0f0;
115
  padding: 2px 6px;
116
- border-radius: 4px;
117
  }
118
 
119
  .result-meta {
@@ -128,7 +128,7 @@
128
  color: #666;
129
  background: #e8e8e8;
130
  padding: 2px 6px;
131
- border-radius: 3px;
132
  }
133
 
134
  .result-snippet {
@@ -141,7 +141,7 @@
141
  .result-snippet mark {
142
  background: #fff3cd;
143
  padding: 1px 2px;
144
- border-radius: 2px;
145
  }
146
 
147
  .search-no-results {
@@ -167,12 +167,12 @@
167
 
168
  .search-results::-webkit-scrollbar-track {
169
  background: #f1f1f1;
170
- border-radius: 4px;
171
  }
172
 
173
  .search-results::-webkit-scrollbar-thumb {
174
  background: #c1c1c1;
175
- border-radius: 4px;
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: 4px;
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: 12px;
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: 3px;
53
  padding: 0.25rem 0.5rem;
54
  font-size: 0.75rem;
55
  cursor: pointer;
56
  color: #333;
57
- font-family: 'Instrument Sans', sans-serif;
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: 4px;
93
  font-size: 0.85rem;
94
- font-family: 'Instrument Sans', sans-serif;
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: 2px;
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: 4px;
127
  font-size: 0.85rem;
128
- font-family: 'Instrument Sans', sans-serif;
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: 3px;
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: 2px;
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: 4px;
258
  }
259
 
260
  .file-tree-container::-webkit-scrollbar-thumb {
261
  background: #c1c1c1;
262
- border-radius: 4px;
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: 8px;
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: 'Instrument Sans', sans-serif;
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: 2px;
73
  transition: all 0.2s;
74
- font-family: 'Instrument Sans', sans-serif;
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: 'Instrument Sans', sans-serif;
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: 4px;
123
  cursor: pointer;
124
  font-size: 0.85rem;
125
- font-family: 'Instrument Sans', sans-serif;
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: 'Instrument Sans', sans-serif;
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: 10px;
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: 'Instrument Sans', sans-serif;
219
  }
220
 
221
  .info-value {
222
  font-size: 1.1rem;
223
  color: #1a1a1a;
224
  font-weight: 500;
225
- font-family: 'Instrument Sans', sans-serif;
226
  }
227
 
228
  .info-value.highlight {
@@ -248,7 +248,7 @@
248
  margin-bottom: 1.5rem;
249
  padding: 1rem;
250
  background: #fafafa;
251
- border-radius: 4px;
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: 'Instrument Sans', sans-serif;
263
  }
264
 
265
  .section-content {
@@ -279,7 +279,7 @@
279
  padding: 0.35rem 0.75rem;
280
  background: #e8f4f8;
281
  color: #1a1a1a;
282
- border-radius: 12px;
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: 'Instrument Sans', sans-serif;
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: 'Instrument Sans', sans-serif;
339
  }
340
 
341
  .modal-info-item span {
342
  font-size: 1rem;
343
  color: #1a1a1a;
344
  font-weight: 500;
345
- font-family: 'Instrument Sans', sans-serif;
346
  }
347
 
348
  .modal-tags {
349
  margin: 0;
350
  padding: 0.75rem;
351
  background: #f5f5f5;
352
- border-radius: 2px;
353
  color: #1a1a1a;
354
  font-size: 0.9rem;
355
  line-height: 1.5;
356
- font-family: 'Instrument Sans', sans-serif;
357
  }
358
 
359
  .modal-footer {
@@ -372,9 +372,9 @@
372
  background: #1a1a1a;
373
  color: #ffffff;
374
  text-decoration: none;
375
- border-radius: 4px;
376
  font-weight: 500;
377
- font-family: 'Instrument Sans', sans-serif;
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: 4px;
424
  padding: 1rem;
425
  }
426
 
@@ -433,7 +433,7 @@
433
  .paper-card {
434
  background: #f9f9f9;
435
  border: 1px solid #e0e0e0;
436
- border-radius: 8px;
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: 4px;
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">📋</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,7 +216,7 @@ export default function ModelModal({
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>
 
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: 8px;
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: 3px;
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: 4px;
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: '8px',
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: '4px',
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: '4px',
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: 8px;
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: 4px;
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: 6px;
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: 4px;
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: 6px;
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: '4px',
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: '4px',
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: 8px;
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: 4px;
46
  padding: 1rem;
47
  margin-bottom: 1rem;
48
  }
@@ -53,7 +53,7 @@
53
  background: #1976d2;
54
  color: white;
55
  border: none;
56
- border-radius: 4px;
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: 8px;
75
  color: white;
76
  }
77
 
@@ -96,7 +96,7 @@
96
  .growth-stats {
97
  padding: 1rem;
98
  background: #f5f5f5;
99
- border-radius: 8px;
100
  }
101
 
102
  .growth-stats h4 {
@@ -115,7 +115,7 @@
115
  text-align: center;
116
  padding: 1rem;
117
  background: white;
118
- border-radius: 6px;
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: 8px;
138
  }
139
 
140
  .breakdown h4 {
@@ -155,7 +155,7 @@
155
  align-items: center;
156
  padding: 0.75rem;
157
  background: white;
158
- border-radius: 4px;
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: 6px;
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: '8px',
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: '4px',
67
  fontSize: '11px',
68
- fontFamily: "'Instrument Sans', sans-serif"
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: 8px;
36
  padding: 1rem;
37
  }
38
 
@@ -66,7 +66,7 @@
66
  flex: 1;
67
  height: 24px;
68
  background: var(--bg-secondary, #f5f5f5);
69
- border-radius: 4px;
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: 4px;
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
-