midah commited on
Commit
c24ac02
·
1 Parent(s): 27b496f

Add 150k model support, background computation, demo GIF, and UI improvements

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +4 -2
  2. backend/api/main.py +359 -0
  3. backend/api/routes/models.py +62 -0
  4. backend/scripts/precompute_background.py +414 -0
  5. frontend/package-lock.json +36 -3
  6. frontend/package.json +3 -0
  7. frontend/public/index.html +4 -0
  8. frontend/src/App.css +1646 -97
  9. frontend/src/App.tsx +413 -1063
  10. frontend/src/components/controls/IntegratedSearch.css +519 -0
  11. frontend/src/components/controls/IntegratedSearch.tsx +435 -0
  12. frontend/src/components/controls/SemanticSearch.css +278 -0
  13. frontend/src/components/controls/SemanticSearch.tsx +398 -0
  14. frontend/src/components/controls/VisualizationModeButtons.tsx +0 -1
  15. frontend/src/components/layout/SearchBar.tsx +4 -4
  16. frontend/src/components/modals/FileTree.css +0 -268
  17. frontend/src/components/modals/FileTree.tsx +0 -509
  18. frontend/src/components/modals/ModelModal.css +0 -533
  19. frontend/src/components/modals/ModelModal.tsx +0 -428
  20. frontend/src/components/ui/ColorLegend.css +88 -20
  21. frontend/src/components/ui/ColorLegend.tsx +158 -60
  22. frontend/src/components/ui/ErrorBoundary.css +62 -0
  23. frontend/src/components/ui/ErrorBoundary.tsx +8 -42
  24. frontend/src/components/ui/IntroModal.css +268 -0
  25. frontend/src/components/ui/IntroModal.tsx +121 -0
  26. frontend/src/components/ui/LiveModelCount.css +34 -0
  27. frontend/src/components/ui/LiveModelCount.tsx +13 -49
  28. frontend/src/components/ui/LiveModelCounter.css +165 -0
  29. frontend/src/components/ui/LiveModelCounter.tsx +226 -0
  30. frontend/src/components/ui/LoadingProgress.css +63 -0
  31. frontend/src/components/ui/LoadingProgress.tsx +35 -0
  32. frontend/src/components/ui/ModelCountTracker.tsx +2 -2
  33. frontend/src/components/ui/ModelPopup.css +317 -0
  34. frontend/src/components/ui/ModelPopup.tsx +243 -0
  35. frontend/src/components/ui/ModelTooltip.css +71 -0
  36. frontend/src/components/ui/ModelTooltip.tsx +20 -48
  37. frontend/src/components/ui/VirtualSearchResults.css +39 -0
  38. frontend/src/components/ui/VirtualSearchResults.tsx +6 -20
  39. frontend/src/components/visualizations/AdoptionCurve.css +138 -0
  40. frontend/src/components/visualizations/AdoptionCurve.tsx +447 -0
  41. frontend/src/components/visualizations/DistanceHeatmap.css +39 -0
  42. frontend/src/components/visualizations/DistanceHeatmap.tsx +7 -28
  43. frontend/src/components/visualizations/MiniMap.css +227 -0
  44. frontend/src/components/visualizations/MiniMap.tsx +317 -0
  45. frontend/src/components/visualizations/MiniMap3D.tsx +342 -0
  46. frontend/src/components/visualizations/NetworkGraph.tsx +0 -1
  47. frontend/src/components/visualizations/ScatterPlot.css +12 -1
  48. frontend/src/components/visualizations/ScatterPlot.tsx +31 -3
  49. frontend/src/components/visualizations/ScatterPlot3D.css +24 -0
  50. frontend/src/components/visualizations/ScatterPlot3D.tsx +271 -269
README.md CHANGED
@@ -12,11 +12,13 @@ Many have observed that the development and deployment of generative machine lea
12
 
13
  This interactive latent space navigator visualizes ~1.84M models from the [modelbiome/ai_ecosystem_withmodelcards](https://huggingface.co/datasets/modelbiome/ai_ecosystem_withmodelcards) dataset in a 2D space where similar models appear closer together, allowing you to explore the relationships and family structures described in the paper.
14
 
 
 
15
  **Resources:**
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
 
@@ -36,7 +38,7 @@ cd ../frontend
36
  npm install && npm start
37
  ```
38
 
39
- **Startup time:** ~5-10 seconds
40
 
41
  ### Option 2: Traditional Mode (Fallback)
42
 
 
12
 
13
  This interactive latent space navigator visualizes ~1.84M models from the [modelbiome/ai_ecosystem_withmodelcards](https://huggingface.co/datasets/modelbiome/ai_ecosystem_withmodelcards) dataset in a 2D space where similar models appear closer together, allowing you to explore the relationships and family structures described in the paper.
14
 
15
+ ![Demo](assets/demo.gif)
16
+
17
  **Resources:**
18
  - **GitHub Repository**: [bendlaufer/ai-ecosystem](https://github.com/bendlaufer/ai-ecosystem) - Original research repository with analysis notebooks and datasets
19
  - **Hugging Face Project**: [modelbiome](https://huggingface.co/modelbiome) - Dataset and project page on Hugging Face Hub
20
 
21
+ ## Quick Start (Pre-Computed Data)
22
 
23
  This project now uses **pre-computed embeddings and coordinates** for instant startup:
24
 
 
38
  npm install && npm start
39
  ```
40
 
41
+ **Startup time:** ~5-10 seconds
42
 
43
  ### Option 2: Traditional Mode (Fallback)
44
 
backend/api/main.py CHANGED
@@ -696,6 +696,91 @@ async def get_family_stats():
696
  }
697
 
698
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  @app.get("/api/family/path/{model_id}")
700
  async def get_family_path(
701
  model_id: str,
@@ -1028,6 +1113,118 @@ async def search_models(
1028
  return {"results": results, "search_type": "basic", "query": search_query}
1029
 
1030
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1031
  @app.get("/api/similar/{model_id}")
1032
  async def get_similar_models(model_id: str, k: int = Query(10, ge=1, le=50)):
1033
  """
@@ -1939,6 +2136,168 @@ async def get_model_files(model_id: str, branch: str = Query("main")):
1939
  )
1940
 
1941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1942
  if __name__ == "__main__":
1943
  import uvicorn
1944
  port = int(os.getenv("PORT", 8000))
 
696
  }
697
 
698
 
699
+ @app.get("/api/family/top")
700
+ async def get_top_families(
701
+ limit: int = Query(50, ge=1, le=200, description="Maximum number of families to return"),
702
+ min_size: int = Query(2, ge=1, description="Minimum family size to include")
703
+ ):
704
+ """
705
+ Get top families by total lineage count (sum of all descendants).
706
+ Calculates the actual family tree size by traversing parent-child relationships.
707
+ """
708
+ if deps.df is None:
709
+ raise DataNotLoadedError()
710
+
711
+ df = deps.df
712
+
713
+ # Build parent -> children mapping
714
+ children_map = {}
715
+ root_models = set()
716
+
717
+ for idx, row in df.iterrows():
718
+ model_id = str(row.get('model_id', ''))
719
+ parent_id = row.get('parent_model')
720
+
721
+ if pd.isna(parent_id) or str(parent_id) == 'nan' or str(parent_id) == '':
722
+ root_models.add(model_id)
723
+ else:
724
+ parent_str = str(parent_id)
725
+ if parent_str not in children_map:
726
+ children_map[parent_str] = []
727
+ children_map[parent_str].append(model_id)
728
+
729
+ # For each root, count all descendants
730
+ def count_descendants(model_id: str, visited: set) -> int:
731
+ if model_id in visited:
732
+ return 0
733
+ visited.add(model_id)
734
+ count = 1 # Count self
735
+ for child in children_map.get(model_id, []):
736
+ count += count_descendants(child, visited)
737
+ return count
738
+
739
+ # Calculate family sizes
740
+ family_data = []
741
+ for root in root_models:
742
+ visited = set()
743
+ total_count = count_descendants(root, visited)
744
+ if total_count >= min_size:
745
+ # Get organization from model_id
746
+ org = root.split('/')[0] if '/' in root else root
747
+ family_data.append({
748
+ "root_model": root,
749
+ "organization": org,
750
+ "total_models": total_count,
751
+ "depth_count": len(visited) # Same as total for tree traversal
752
+ })
753
+
754
+ # Sort by total count descending
755
+ family_data.sort(key=lambda x: x['total_models'], reverse=True)
756
+
757
+ # Also aggregate by organization (sum all families under same org)
758
+ org_totals = {}
759
+ for fam in family_data:
760
+ org = fam['organization']
761
+ if org not in org_totals:
762
+ org_totals[org] = {
763
+ "organization": org,
764
+ "total_models": 0,
765
+ "family_count": 0,
766
+ "root_models": []
767
+ }
768
+ org_totals[org]['total_models'] += fam['total_models']
769
+ org_totals[org]['family_count'] += 1
770
+ if len(org_totals[org]['root_models']) < 5: # Keep top 5 root models
771
+ org_totals[org]['root_models'].append(fam['root_model'])
772
+
773
+ # Sort organizations by total models
774
+ top_orgs = sorted(org_totals.values(), key=lambda x: x['total_models'], reverse=True)[:limit]
775
+
776
+ return {
777
+ "families": family_data[:limit],
778
+ "organizations": top_orgs,
779
+ "total_families": len(family_data),
780
+ "total_root_models": len(root_models)
781
+ }
782
+
783
+
784
  @app.get("/api/family/path/{model_id}")
785
  async def get_family_path(
786
  model_id: str,
 
1113
  return {"results": results, "search_type": "basic", "query": search_query}
1114
 
1115
 
1116
+ @app.get("/api/search/fuzzy")
1117
+ async def fuzzy_search_models(
1118
+ q: str = Query(..., min_length=2, description="Search query"),
1119
+ limit: int = Query(50, ge=1, le=200, description="Maximum number of results"),
1120
+ threshold: int = Query(60, ge=0, le=100, description="Minimum fuzzy match score (0-100)"),
1121
+ ):
1122
+ """
1123
+ Fuzzy search for models using rapidfuzz.
1124
+ Handles typos and partial matches across model names, libraries, and pipelines.
1125
+ Returns results sorted by relevance score.
1126
+ """
1127
+ if deps.df is None:
1128
+ raise DataNotLoadedError()
1129
+
1130
+ df = deps.df
1131
+
1132
+ try:
1133
+ from rapidfuzz import fuzz, process
1134
+ from rapidfuzz.utils import default_process
1135
+
1136
+ query_lower = q.lower().strip()
1137
+
1138
+ # Prepare choices - combine model_id, library, and pipeline for searching
1139
+ # Create a searchable string for each model
1140
+ model_ids = df['model_id'].astype(str).tolist()
1141
+ libraries = df.get('library_name', pd.Series([''] * len(df))).fillna('').astype(str).tolist()
1142
+ pipelines = df.get('pipeline_tag', pd.Series([''] * len(df))).fillna('').astype(str).tolist()
1143
+
1144
+ # Create search strings - just model_id for better fuzzy matching
1145
+ # Library and pipeline are used for secondary filtering
1146
+ search_strings = [m.lower() for m in model_ids]
1147
+
1148
+ # Use rapidfuzz to find best matches
1149
+ # WRatio is best for general fuzzy matching with typo tolerance
1150
+ # It handles transpositions, insertions, deletions well
1151
+
1152
+ # extract returns list of (match, score, index)
1153
+ matches = process.extract(
1154
+ query_lower,
1155
+ search_strings,
1156
+ scorer=fuzz.WRatio,
1157
+ limit=limit * 3, # Get extra to filter by threshold and dedupe
1158
+ score_cutoff=threshold,
1159
+ processor=default_process
1160
+ )
1161
+
1162
+ # Also try partial matching for substring searches
1163
+ if len(matches) < limit:
1164
+ partial_matches = process.extract(
1165
+ query_lower,
1166
+ search_strings,
1167
+ scorer=fuzz.partial_ratio,
1168
+ limit=limit * 2,
1169
+ score_cutoff=threshold + 10, # Higher threshold for partial
1170
+ processor=default_process
1171
+ )
1172
+ # Add unique partial matches
1173
+ seen_indices = {m[2] for m in matches}
1174
+ for m in partial_matches:
1175
+ if m[2] not in seen_indices:
1176
+ matches.append(m)
1177
+ seen_indices.add(m[2])
1178
+
1179
+ results = []
1180
+ seen_ids = set()
1181
+
1182
+ for match_str, score, idx in matches:
1183
+ if len(results) >= limit:
1184
+ break
1185
+
1186
+ model_id = model_ids[idx]
1187
+ if model_id in seen_ids:
1188
+ continue
1189
+ seen_ids.add(model_id)
1190
+
1191
+ row = df.iloc[idx]
1192
+
1193
+ # Get coordinates
1194
+ x = float(row.get('x', 0.0)) if 'x' in row else None
1195
+ y = float(row.get('y', 0.0)) if 'y' in row else None
1196
+ z = float(row.get('z', 0.0)) if 'z' in row else None
1197
+
1198
+ results.append({
1199
+ "model_id": model_id,
1200
+ "x": x,
1201
+ "y": y,
1202
+ "z": z,
1203
+ "score": round(score, 1),
1204
+ "library": row.get('library_name') if pd.notna(row.get('library_name')) else None,
1205
+ "pipeline": row.get('pipeline_tag') if pd.notna(row.get('pipeline_tag')) else None,
1206
+ "downloads": int(row.get('downloads', 0)),
1207
+ "likes": int(row.get('likes', 0)),
1208
+ "family_depth": int(row.get('family_depth', 0)) if pd.notna(row.get('family_depth')) else None,
1209
+ })
1210
+
1211
+ # Sort by score descending, then by downloads for tie-breaking
1212
+ results.sort(key=lambda x: (-x['score'], -x['downloads']))
1213
+
1214
+ return {
1215
+ "results": results,
1216
+ "query": q,
1217
+ "total_matches": len(matches),
1218
+ "threshold": threshold
1219
+ }
1220
+
1221
+ except ImportError:
1222
+ raise HTTPException(status_code=500, detail="rapidfuzz not installed")
1223
+ except Exception as e:
1224
+ logger.exception(f"Fuzzy search error: {e}")
1225
+ raise HTTPException(status_code=500, detail=f"Search error: {str(e)}")
1226
+
1227
+
1228
  @app.get("/api/similar/{model_id}")
1229
  async def get_similar_models(model_id: str, k: int = Query(10, ge=1, le=50)):
1230
  """
 
2136
  )
2137
 
2138
 
2139
+ # =============================================================================
2140
+ # BACKGROUND COMPUTATION ENDPOINTS
2141
+ # =============================================================================
2142
+
2143
+ import subprocess
2144
+ import threading
2145
+
2146
+ # Store for background process
2147
+ _background_process = None
2148
+ _background_lock = threading.Lock()
2149
+
2150
+
2151
+ class ComputeRequest(BaseModel):
2152
+ sample_size: Optional[int] = None
2153
+ all_models: bool = False
2154
+
2155
+
2156
+ @app.get("/api/compute/status")
2157
+ async def get_compute_status():
2158
+ """Get the status of background pre-computation."""
2159
+ from pathlib import Path
2160
+
2161
+ root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
2162
+ status_file = Path(root_dir) / "precomputed_data" / "background_status_v1.json"
2163
+
2164
+ if status_file.exists():
2165
+ import json
2166
+ with open(status_file, 'r') as f:
2167
+ status = json.load(f)
2168
+
2169
+ # Check if process is still running
2170
+ global _background_process
2171
+ with _background_lock:
2172
+ if _background_process is not None:
2173
+ poll = _background_process.poll()
2174
+ if poll is None:
2175
+ status['process_running'] = True
2176
+ else:
2177
+ status['process_running'] = False
2178
+ status['process_exit_code'] = poll
2179
+ else:
2180
+ status['process_running'] = False
2181
+
2182
+ return status
2183
+
2184
+ # Check for existing precomputed data
2185
+ metadata_file = Path(root_dir) / "precomputed_data" / "metadata_v1.json"
2186
+ models_file = Path(root_dir) / "precomputed_data" / "models_v1.parquet"
2187
+
2188
+ if metadata_file.exists() and models_file.exists():
2189
+ import json
2190
+ with open(metadata_file, 'r') as f:
2191
+ metadata = json.load(f)
2192
+ return {
2193
+ 'status': 'completed',
2194
+ 'total_models': metadata.get('total_models', 0),
2195
+ 'created_at': metadata.get('created_at'),
2196
+ 'process_running': False
2197
+ }
2198
+
2199
+ return {
2200
+ 'status': 'not_started',
2201
+ 'total_models': 0,
2202
+ 'process_running': False
2203
+ }
2204
+
2205
+
2206
+ @app.post("/api/compute/start")
2207
+ async def start_background_compute(request: ComputeRequest, background_tasks: BackgroundTasks):
2208
+ """Start background pre-computation of model embeddings."""
2209
+ global _background_process
2210
+
2211
+ with _background_lock:
2212
+ if _background_process is not None and _background_process.poll() is None:
2213
+ raise HTTPException(
2214
+ status_code=409,
2215
+ detail="Background computation is already running"
2216
+ )
2217
+
2218
+ # Prepare command
2219
+ root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
2220
+ script_path = os.path.join(root_dir, "backend", "scripts", "precompute_background.py")
2221
+ venv_python = os.path.join(root_dir, "venv", "bin", "python")
2222
+
2223
+ cmd = [venv_python, script_path]
2224
+
2225
+ if request.all_models:
2226
+ cmd.append("--all")
2227
+ elif request.sample_size:
2228
+ cmd.extend(["--sample-size", str(request.sample_size)])
2229
+ else:
2230
+ cmd.extend(["--sample-size", "150000"]) # Default
2231
+
2232
+ cmd.extend(["--output-dir", os.path.join(root_dir, "precomputed_data")])
2233
+
2234
+ # Start process in background
2235
+ log_file = os.path.join(root_dir, "precompute_background.log")
2236
+
2237
+ def run_computation():
2238
+ global _background_process
2239
+ with open(log_file, 'w') as f:
2240
+ with _background_lock:
2241
+ _background_process = subprocess.Popen(
2242
+ cmd,
2243
+ stdout=f,
2244
+ stderr=subprocess.STDOUT,
2245
+ cwd=os.path.join(root_dir, "backend")
2246
+ )
2247
+ _background_process.wait()
2248
+
2249
+ thread = threading.Thread(target=run_computation, daemon=True)
2250
+ thread.start()
2251
+
2252
+ sample_desc = "all models" if request.all_models else f"{request.sample_size or 150000:,} models"
2253
+
2254
+ return {
2255
+ "message": f"Background computation started for {sample_desc}",
2256
+ "status": "starting",
2257
+ "log_file": log_file
2258
+ }
2259
+
2260
+
2261
+ @app.post("/api/compute/stop")
2262
+ async def stop_background_compute():
2263
+ """Stop the running background computation."""
2264
+ global _background_process
2265
+
2266
+ with _background_lock:
2267
+ if _background_process is None or _background_process.poll() is not None:
2268
+ return {"message": "No computation is running"}
2269
+
2270
+ _background_process.terminate()
2271
+ try:
2272
+ _background_process.wait(timeout=5)
2273
+ except subprocess.TimeoutExpired:
2274
+ _background_process.kill()
2275
+
2276
+ return {"message": "Background computation stopped"}
2277
+
2278
+
2279
+ @app.get("/api/data/info")
2280
+ async def get_data_info():
2281
+ """Get information about currently loaded data."""
2282
+ df = deps.df
2283
+
2284
+ if df is None:
2285
+ return {
2286
+ "loaded": False,
2287
+ "message": "No data loaded"
2288
+ }
2289
+
2290
+ return {
2291
+ "loaded": True,
2292
+ "total_models": len(df),
2293
+ "columns": list(df.columns),
2294
+ "unique_libraries": int(df['library_name'].nunique()) if 'library_name' in df.columns else 0,
2295
+ "unique_pipelines": int(df['pipeline_tag'].nunique()) if 'pipeline_tag' in df.columns else 0,
2296
+ "has_3d_coords": all(col in df.columns for col in ['x_3d', 'y_3d', 'z_3d']),
2297
+ "has_2d_coords": all(col in df.columns for col in ['x_2d', 'y_2d'])
2298
+ }
2299
+
2300
+
2301
  if __name__ == "__main__":
2302
  import uvicorn
2303
  port = int(os.getenv("PORT", 8000))
backend/api/routes/models.py CHANGED
@@ -13,6 +13,7 @@ from umap import UMAP
13
  from models.schemas import ModelPoint
14
  from utils.family_tree import calculate_family_depths
15
  from utils.dimensionality_reduction import DimensionReducer
 
16
  from core.exceptions import DataNotLoadedError, EmbeddingsNotReadyError
17
  import api.dependencies as deps
18
 
@@ -245,3 +246,64 @@ async def get_models(
245
  "returned_count": len(models)
246
  }
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  from models.schemas import ModelPoint
14
  from utils.family_tree import calculate_family_depths
15
  from utils.dimensionality_reduction import DimensionReducer
16
+ from utils.cache import cached_response
17
  from core.exceptions import DataNotLoadedError, EmbeddingsNotReadyError
18
  import api.dependencies as deps
19
 
 
246
  "returned_count": len(models)
247
  }
248
 
249
+
250
+ @router.get("/family/adoption")
251
+ @cached_response(ttl=3600, key_prefix="family_adoption")
252
+ async def get_family_adoption(
253
+ family: str = Query(..., description="Family name (e.g., 'meta-llama', 'google', 'microsoft')"),
254
+ limit: int = Query(100, ge=1, le=1000, description="Maximum number of models to return")
255
+ ):
256
+ """
257
+ Get adoption data for a specific family (S-curve data).
258
+ Returns models sorted by creation date with their downloads.
259
+ """
260
+ if deps.df is None:
261
+ raise DataNotLoadedError()
262
+
263
+ df = deps.df
264
+
265
+ # Filter by family name (check model_id prefix and tags)
266
+ family_lower = family.lower()
267
+ filtered_df = df[
268
+ df['model_id'].astype(str).str.lower().str.contains(family_lower, regex=False, na=False) |
269
+ df.get('tags', pd.Series([None] * len(df))).astype(str).str.lower().str.contains(family_lower, regex=False, na=False)
270
+ ]
271
+
272
+ if len(filtered_df) == 0:
273
+ return {
274
+ "family": family,
275
+ "models": [],
276
+ "total_models": 0
277
+ }
278
+
279
+ # Sort by downloads and limit
280
+ filtered_df = filtered_df.nlargest(limit, 'downloads', keep='first')
281
+
282
+ # Extract required fields
283
+ model_ids = filtered_df['model_id'].astype(str).values
284
+ downloads_arr = filtered_df.get('downloads', pd.Series([0] * len(filtered_df))).fillna(0).astype(int).values
285
+ created_at_arr = filtered_df.get('createdAt', pd.Series([None] * len(filtered_df))).values
286
+
287
+ # Parse dates efficiently
288
+ dates = pd.to_datetime(created_at_arr, errors='coerce', utc=True)
289
+
290
+ # Build response
291
+ adoption_data = []
292
+ for idx in range(len(filtered_df)):
293
+ date_val = dates.iloc[idx] if isinstance(dates, pd.Series) else dates[idx]
294
+ if pd.notna(date_val):
295
+ adoption_data.append({
296
+ "model_id": model_ids[idx],
297
+ "downloads": int(downloads_arr[idx]),
298
+ "created_at": date_val.isoformat()
299
+ })
300
+
301
+ # Sort by date
302
+ adoption_data.sort(key=lambda x: x['created_at'] if x['created_at'] else '')
303
+
304
+ return {
305
+ "family": family,
306
+ "models": adoption_data,
307
+ "total_models": len(adoption_data)
308
+ }
309
+
backend/scripts/precompute_background.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Background pre-computation script for processing ALL models incrementally.
4
+ Designed to run in the background and save progress so it can be resumed.
5
+
6
+ Features:
7
+ - Processes models in batches to manage memory
8
+ - Saves progress incrementally
9
+ - Can be resumed if interrupted
10
+ - Provides status updates via JSON file
11
+
12
+ Usage:
13
+ # Process all models (default ~500k batch)
14
+ python scripts/precompute_background.py --all
15
+
16
+ # Process specific number of models
17
+ python scripts/precompute_background.py --sample-size 500000
18
+
19
+ # Resume from previous run
20
+ python scripts/precompute_background.py --resume
21
+
22
+ # Check status
23
+ python scripts/precompute_background.py --status
24
+ """
25
+
26
+ import argparse
27
+ import os
28
+ import sys
29
+ import json
30
+ import time
31
+ import logging
32
+ import signal
33
+ from datetime import datetime
34
+ from pathlib import Path
35
+ from typing import Optional, Dict, Any
36
+ import threading
37
+
38
+ import pandas as pd
39
+ import numpy as np
40
+ from umap import UMAP
41
+ from sklearn.decomposition import PCA, IncrementalPCA
42
+
43
+ # Add backend to path
44
+ backend_dir = Path(__file__).parent.parent
45
+ sys.path.insert(0, str(backend_dir))
46
+
47
+ from utils.data_loader import ModelDataLoader
48
+ from utils.embeddings import ModelEmbedder
49
+
50
+ logging.basicConfig(
51
+ level=logging.INFO,
52
+ format='%(asctime)s - %(levelname)s - %(message)s',
53
+ handlers=[
54
+ logging.StreamHandler(),
55
+ logging.FileHandler('precompute_background.log')
56
+ ]
57
+ )
58
+ logger = logging.getLogger(__name__)
59
+
60
+ # Global flag for graceful shutdown
61
+ shutdown_requested = False
62
+
63
+ def signal_handler(signum, frame):
64
+ global shutdown_requested
65
+ logger.warning("Shutdown requested - will save progress and exit...")
66
+ shutdown_requested = True
67
+
68
+ signal.signal(signal.SIGINT, signal_handler)
69
+ signal.signal(signal.SIGTERM, signal_handler)
70
+
71
+
72
+ class BackgroundPrecomputer:
73
+ """Handles incremental pre-computation of model embeddings and coordinates."""
74
+
75
+ def __init__(
76
+ self,
77
+ output_dir: str = "precomputed_data",
78
+ version: str = "v1",
79
+ batch_size: int = 50000,
80
+ embedding_batch_size: int = 256
81
+ ):
82
+ self.output_dir = Path(output_dir)
83
+ self.version = version
84
+ self.batch_size = batch_size
85
+ self.embedding_batch_size = embedding_batch_size
86
+
87
+ # Status file for tracking progress
88
+ self.status_file = self.output_dir / f"background_status_{version}.json"
89
+ self.output_dir.mkdir(parents=True, exist_ok=True)
90
+
91
+ # Initialize components
92
+ self.data_loader = ModelDataLoader()
93
+ self.embedder = ModelEmbedder()
94
+
95
+ def get_status(self) -> Dict[str, Any]:
96
+ """Get current computation status."""
97
+ if self.status_file.exists():
98
+ with open(self.status_file, 'r') as f:
99
+ return json.load(f)
100
+ return {
101
+ 'status': 'not_started',
102
+ 'total_models': 0,
103
+ 'processed_models': 0,
104
+ 'current_batch': 0,
105
+ 'started_at': None,
106
+ 'last_updated': None,
107
+ 'error': None
108
+ }
109
+
110
+ def save_status(self, status: Dict[str, Any]):
111
+ """Save computation status."""
112
+ status['last_updated'] = datetime.now().isoformat()
113
+ with open(self.status_file, 'w') as f:
114
+ json.dump(status, f, indent=2)
115
+
116
+ def load_full_dataset(self) -> pd.DataFrame:
117
+ """Load the full dataset without sampling."""
118
+ logger.info("Loading full dataset from HuggingFace...")
119
+ from datasets import load_dataset
120
+ dataset = load_dataset("modelbiome/ai_ecosystem", split="train")
121
+ df = dataset.to_pandas()
122
+ logger.info(f"Loaded {len(df):,} total models")
123
+ return df
124
+
125
+ def precompute_all(
126
+ self,
127
+ sample_size: Optional[int] = None,
128
+ resume: bool = False,
129
+ pca_dims: int = 50
130
+ ):
131
+ """
132
+ Pre-compute embeddings and coordinates for all or specified number of models.
133
+
134
+ Args:
135
+ sample_size: If None, process all models. Otherwise, process this many.
136
+ resume: If True, resume from previous progress
137
+ pca_dims: Number of PCA dimensions for pre-reduction
138
+ """
139
+ global shutdown_requested
140
+
141
+ start_time = time.time()
142
+
143
+ # Get current status
144
+ status = self.get_status() if resume else {
145
+ 'status': 'initializing',
146
+ 'total_models': 0,
147
+ 'processed_models': 0,
148
+ 'current_batch': 0,
149
+ 'started_at': datetime.now().isoformat(),
150
+ 'error': None,
151
+ 'batches_completed': []
152
+ }
153
+
154
+ try:
155
+ # Step 1: Load data
156
+ status['status'] = 'loading_data'
157
+ self.save_status(status)
158
+
159
+ if sample_size:
160
+ logger.info(f"Loading {sample_size:,} models with stratified sampling...")
161
+ df = self.data_loader.load_data(sample_size=sample_size, prioritize_base_models=True)
162
+ else:
163
+ logger.info("Loading ALL models...")
164
+ df = self.load_full_dataset()
165
+
166
+ total_models = len(df)
167
+ status['total_models'] = total_models
168
+ logger.info(f"Total models to process: {total_models:,}")
169
+
170
+ # Build combined text
171
+ logger.info("Building combined text for embeddings...")
172
+ df['combined_text'] = (
173
+ df.get('tags', '').astype(str) + ' ' +
174
+ df.get('pipeline_tag', '').astype(str) + ' ' +
175
+ df.get('library_name', '').astype(str)
176
+ )
177
+ if 'modelCard' in df.columns:
178
+ df['combined_text'] = df['combined_text'] + ' ' + df['modelCard'].astype(str).str[:500]
179
+
180
+ # Step 2: Generate embeddings in batches
181
+ status['status'] = 'generating_embeddings'
182
+ self.save_status(status)
183
+
184
+ logger.info("Generating embeddings...")
185
+ all_embeddings = []
186
+ texts = df['combined_text'].tolist()
187
+
188
+ num_batches = (len(texts) + self.batch_size - 1) // self.batch_size
189
+
190
+ for batch_idx in range(num_batches):
191
+ if shutdown_requested:
192
+ logger.warning("Shutdown requested - saving partial progress...")
193
+ break
194
+
195
+ batch_start = batch_idx * self.batch_size
196
+ batch_end = min(batch_start + self.batch_size, len(texts))
197
+ batch_texts = texts[batch_start:batch_end]
198
+
199
+ logger.info(f"Processing embedding batch {batch_idx + 1}/{num_batches} "
200
+ f"(models {batch_start:,} - {batch_end:,})...")
201
+
202
+ batch_embeddings = self.embedder.generate_embeddings(
203
+ batch_texts,
204
+ batch_size=self.embedding_batch_size
205
+ )
206
+ all_embeddings.append(batch_embeddings)
207
+
208
+ status['processed_models'] = batch_end
209
+ status['current_batch'] = batch_idx + 1
210
+ status['progress_percent'] = round(100 * batch_end / total_models, 1)
211
+ self.save_status(status)
212
+
213
+ if shutdown_requested:
214
+ status['status'] = 'interrupted'
215
+ self.save_status(status)
216
+ return
217
+
218
+ embeddings = np.vstack(all_embeddings)
219
+ logger.info(f"Generated embeddings: {embeddings.shape}")
220
+
221
+ # Step 3: PCA pre-reduction
222
+ status['status'] = 'pca_reduction'
223
+ self.save_status(status)
224
+
225
+ logger.info(f"Applying PCA reduction ({embeddings.shape[1]} -> {pca_dims} dims)...")
226
+ pca = PCA(n_components=pca_dims, random_state=42)
227
+ embeddings_reduced = pca.fit_transform(embeddings)
228
+ explained_var = pca.explained_variance_ratio_.sum()
229
+ logger.info(f"PCA complete (preserved {explained_var:.1%} variance)")
230
+
231
+ if shutdown_requested:
232
+ status['status'] = 'interrupted'
233
+ self.save_status(status)
234
+ return
235
+
236
+ # Step 4: UMAP 3D
237
+ status['status'] = 'umap_3d'
238
+ self.save_status(status)
239
+
240
+ logger.info("Running UMAP for 3D coordinates...")
241
+ reducer_3d = UMAP(
242
+ n_components=3,
243
+ n_neighbors=15,
244
+ min_dist=0.1,
245
+ metric='euclidean',
246
+ n_jobs=-1,
247
+ low_memory=True if total_models > 200000 else False,
248
+ spread=1.5,
249
+ verbose=True
250
+ )
251
+ coords_3d = reducer_3d.fit_transform(embeddings_reduced)
252
+ logger.info(f"3D coordinates: {coords_3d.shape}")
253
+
254
+ if shutdown_requested:
255
+ status['status'] = 'interrupted'
256
+ self.save_status(status)
257
+ return
258
+
259
+ # Step 5: UMAP 2D
260
+ status['status'] = 'umap_2d'
261
+ self.save_status(status)
262
+
263
+ logger.info("Running UMAP for 2D coordinates...")
264
+ reducer_2d = UMAP(
265
+ n_components=2,
266
+ n_neighbors=15,
267
+ min_dist=0.1,
268
+ metric='euclidean',
269
+ n_jobs=-1,
270
+ low_memory=True if total_models > 200000 else False,
271
+ spread=1.5,
272
+ verbose=True
273
+ )
274
+ coords_2d = reducer_2d.fit_transform(embeddings_reduced)
275
+ logger.info(f"2D coordinates: {coords_2d.shape}")
276
+
277
+ # Step 6: Save results
278
+ status['status'] = 'saving'
279
+ self.save_status(status)
280
+
281
+ logger.info("Saving results...")
282
+
283
+ # Prepare output DataFrame
284
+ output_df = df.copy()
285
+ output_df['x_3d'] = coords_3d[:, 0]
286
+ output_df['y_3d'] = coords_3d[:, 1]
287
+ output_df['z_3d'] = coords_3d[:, 2]
288
+ output_df['x_2d'] = coords_2d[:, 0]
289
+ output_df['y_2d'] = coords_2d[:, 1]
290
+
291
+ # Save models
292
+ models_file = self.output_dir / f"models_{self.version}.parquet"
293
+ output_df.to_parquet(models_file, compression='snappy', index=False)
294
+ logger.info(f"Saved: {models_file} ({models_file.stat().st_size / 1024 / 1024:.1f} MB)")
295
+
296
+ # Save embeddings
297
+ embeddings_file = self.output_dir / f"embeddings_{self.version}.parquet"
298
+ embeddings_df = pd.DataFrame({
299
+ 'model_id': df['modelId'].values,
300
+ 'embedding': [emb.tolist() for emb in embeddings]
301
+ })
302
+ embeddings_df.to_parquet(embeddings_file, compression='snappy', index=False)
303
+ logger.info(f"Saved: {embeddings_file} ({embeddings_file.stat().st_size / 1024 / 1024:.1f} MB)")
304
+
305
+ # Save metadata
306
+ total_time = time.time() - start_time
307
+ metadata = {
308
+ 'version': self.version,
309
+ 'created_at': datetime.now().isoformat(),
310
+ 'total_models': int(total_models),
311
+ 'embedding_dim': int(embeddings.shape[1]),
312
+ 'umap_3d_shape': list(coords_3d.shape),
313
+ 'umap_2d_shape': list(coords_2d.shape),
314
+ 'unique_libraries': int(df['library_name'].nunique()),
315
+ 'unique_pipelines': int(df['pipeline_tag'].nunique()),
316
+ 'processing_time_seconds': total_time,
317
+ 'processing_time_hours': total_time / 3600,
318
+ 'pca_dims': pca_dims,
319
+ 'pca_variance_preserved': float(explained_var)
320
+ }
321
+
322
+ metadata_file = self.output_dir / f"metadata_{self.version}.json"
323
+ with open(metadata_file, 'w') as f:
324
+ json.dump(metadata, f, indent=2)
325
+ logger.info(f"Saved: {metadata_file}")
326
+
327
+ # Update final status
328
+ status['status'] = 'completed'
329
+ status['completed_at'] = datetime.now().isoformat()
330
+ status['processing_time_hours'] = round(total_time / 3600, 2)
331
+ self.save_status(status)
332
+
333
+ logger.info("="*60)
334
+ logger.info("BACKGROUND PRE-COMPUTATION COMPLETE!")
335
+ logger.info("="*60)
336
+ logger.info(f"Total time: {total_time/3600:.2f} hours ({total_time/60:.1f} minutes)")
337
+ logger.info(f"Models processed: {total_models:,}")
338
+ logger.info(f"Output directory: {self.output_dir}")
339
+ logger.info("="*60)
340
+
341
+ except Exception as e:
342
+ logger.error(f"Pre-computation failed: {e}", exc_info=True)
343
+ status['status'] = 'failed'
344
+ status['error'] = str(e)
345
+ self.save_status(status)
346
+ raise
347
+
348
+
349
+ def main():
350
+ parser = argparse.ArgumentParser(
351
+ description="Background pre-computation of HF model embeddings and coordinates"
352
+ )
353
+ parser.add_argument(
354
+ "--sample-size", type=int, default=None,
355
+ help="Number of models to process (default: all)"
356
+ )
357
+ parser.add_argument(
358
+ "--all", action="store_true",
359
+ help="Process ALL models (may take many hours)"
360
+ )
361
+ parser.add_argument(
362
+ "--resume", action="store_true",
363
+ help="Resume from previous progress"
364
+ )
365
+ parser.add_argument(
366
+ "--status", action="store_true",
367
+ help="Show current computation status and exit"
368
+ )
369
+ parser.add_argument(
370
+ "--output-dir", type=str, default="../precomputed_data",
371
+ help="Output directory"
372
+ )
373
+ parser.add_argument(
374
+ "--version", type=str, default="v1",
375
+ help="Version tag"
376
+ )
377
+ parser.add_argument(
378
+ "--batch-size", type=int, default=50000,
379
+ help="Batch size for processing"
380
+ )
381
+
382
+ args = parser.parse_args()
383
+
384
+ precomputer = BackgroundPrecomputer(
385
+ output_dir=args.output_dir,
386
+ version=args.version,
387
+ batch_size=args.batch_size
388
+ )
389
+
390
+ if args.status:
391
+ status = precomputer.get_status()
392
+ print(json.dumps(status, indent=2))
393
+ return
394
+
395
+ sample_size = None if args.all else (args.sample_size or 150000)
396
+
397
+ if sample_size:
398
+ logger.info(f"Processing {sample_size:,} models...")
399
+ else:
400
+ logger.info("Processing ALL models (this may take many hours)...")
401
+
402
+ try:
403
+ precomputer.precompute_all(
404
+ sample_size=sample_size,
405
+ resume=args.resume
406
+ )
407
+ except Exception as e:
408
+ logger.error(f"Failed: {e}")
409
+ sys.exit(1)
410
+
411
+
412
+ if __name__ == "__main__":
413
+ main()
414
+
frontend/package-lock.json CHANGED
@@ -29,7 +29,10 @@
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",
@@ -5328,6 +5331,18 @@
5328
  "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
5329
  "license": "MIT"
5330
  },
 
 
 
 
 
 
 
 
 
 
 
 
5331
  "node_modules/@visx/visx": {
5332
  "version": "3.12.0",
5333
  "resolved": "https://registry.npmjs.org/@visx/visx/-/visx-3.12.0.tgz",
@@ -7853,9 +7868,9 @@
7853
  }
7854
  },
7855
  "node_modules/d3-array": {
7856
- "version": "3.2.1",
7857
- "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
7858
- "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
7859
  "license": "ISC",
7860
  "dependencies": {
7861
  "internmap": "1 - 2"
@@ -10484,6 +10499,15 @@
10484
  "url": "https://github.com/sponsors/ljharb"
10485
  }
10486
  },
 
 
 
 
 
 
 
 
 
10487
  "node_modules/generator-function": {
10488
  "version": "2.0.1",
10489
  "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -13364,6 +13388,15 @@
13364
  "yallist": "^3.0.2"
13365
  }
13366
  },
 
 
 
 
 
 
 
 
 
13367
  "node_modules/maath": {
13368
  "version": "0.10.8",
13369
  "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
 
29
  "axios": "^1.6.0",
30
  "comlink": "^4.4.1",
31
  "d3": "^7.8.5",
32
+ "d3-array": "^3.2.4",
33
+ "fuse.js": "^7.1.0",
34
  "idb": "^8.0.0",
35
+ "lucide-react": "^0.555.0",
36
  "msgpack-lite": "^0.1.26",
37
  "react": "^18.2.0",
38
  "react-dom": "^18.2.0",
 
5331
  "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
5332
  "license": "MIT"
5333
  },
5334
+ "node_modules/@visx/vendor/node_modules/d3-array": {
5335
+ "version": "3.2.1",
5336
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
5337
+ "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
5338
+ "license": "ISC",
5339
+ "dependencies": {
5340
+ "internmap": "1 - 2"
5341
+ },
5342
+ "engines": {
5343
+ "node": ">=12"
5344
+ }
5345
+ },
5346
  "node_modules/@visx/visx": {
5347
  "version": "3.12.0",
5348
  "resolved": "https://registry.npmjs.org/@visx/visx/-/visx-3.12.0.tgz",
 
7868
  }
7869
  },
7870
  "node_modules/d3-array": {
7871
+ "version": "3.2.4",
7872
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
7873
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
7874
  "license": "ISC",
7875
  "dependencies": {
7876
  "internmap": "1 - 2"
 
10499
  "url": "https://github.com/sponsors/ljharb"
10500
  }
10501
  },
10502
+ "node_modules/fuse.js": {
10503
+ "version": "7.1.0",
10504
+ "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
10505
+ "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
10506
+ "license": "Apache-2.0",
10507
+ "engines": {
10508
+ "node": ">=10"
10509
+ }
10510
+ },
10511
  "node_modules/generator-function": {
10512
  "version": "2.0.1",
10513
  "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
 
13388
  "yallist": "^3.0.2"
13389
  }
13390
  },
13391
+ "node_modules/lucide-react": {
13392
+ "version": "0.555.0",
13393
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
13394
+ "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
13395
+ "license": "ISC",
13396
+ "peerDependencies": {
13397
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
13398
+ }
13399
+ },
13400
  "node_modules/maath": {
13401
  "version": "0.10.8",
13402
  "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
frontend/package.json CHANGED
@@ -25,7 +25,10 @@
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",
 
25
  "axios": "^1.6.0",
26
  "comlink": "^4.4.1",
27
  "d3": "^7.8.5",
28
+ "d3-array": "^3.2.4",
29
+ "fuse.js": "^7.1.0",
30
  "idb": "^8.0.0",
31
+ "lucide-react": "^0.555.0",
32
  "msgpack-lite": "^0.1.26",
33
  "react": "^18.2.0",
34
  "react-dom": "^18.2.0",
frontend/public/index.html CHANGED
@@ -4,6 +4,10 @@
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <meta name="theme-color" content="#000000" />
 
 
 
 
7
  <meta
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."
 
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <meta name="theme-color" content="#000000" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Overpass:ital,wght@0,100..900;1,100..900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
10
+ <noscript><link href="https://fonts.googleapis.com/css2?family=Overpass:ital,wght@0,100..900;1,100..900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet" /></noscript>
11
  <meta
12
  name="description"
13
  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."
frontend/src/App.css CHANGED
@@ -2,8 +2,8 @@
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;
@@ -65,112 +65,1295 @@
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
  }
@@ -179,6 +1362,79 @@
179
  border-color: var(--border-medium);
180
  box-shadow: var(--shadow-sm);
181
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  /* ============================================
184
  FORM ELEMENTS
@@ -248,11 +1504,10 @@
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;
@@ -265,11 +1520,10 @@
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"] {
@@ -279,6 +1533,83 @@
279
  accent-color: var(--accent-primary);
280
  margin-right: 0.5rem;
281
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  /* ============================================
284
  BUTTONS
@@ -304,11 +1635,10 @@
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);
@@ -343,26 +1673,26 @@
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 {
@@ -546,10 +1876,14 @@
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;
@@ -586,10 +1920,10 @@
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
@@ -634,10 +1968,9 @@
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;
@@ -649,10 +1982,9 @@
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;
@@ -699,11 +2031,11 @@
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
 
@@ -719,6 +2051,12 @@
719
  border: 1px solid #ffcdd2;
720
  font-weight: 500;
721
  }
 
 
 
 
 
 
722
 
723
  .empty {
724
  color: var(--text-secondary);
@@ -767,4 +2105,215 @@
767
  .visualization svg {
768
  display: block;
769
  background: var(--bg-primary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  }
 
2
  ROOT & VARIABLES
3
  ============================================ */
4
  :root {
5
+ --font-primary: 'Overpass', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
6
+ --font-mono: 'Roboto Mono', 'Monaco', 'Menlo', 'Courier New', monospace;
7
 
8
  /* Light theme (default) */
9
  --bg-primary: #ffffff;
 
65
  -moz-osx-font-smoothing: grayscale;
66
  color: var(--text-primary);
67
  background: var(--bg-secondary);
68
+ min-height: 100vh;
69
+ }
70
+
71
+ /* ============================================
72
+ APP LAYOUT
73
+ ============================================ */
74
+ .app-layout {
75
+ display: flex;
76
+ min-height: 100vh;
77
+ overflow-x: hidden;
78
+ width: 100%;
79
+ background: var(--bg-primary);
80
+ }
81
+
82
+ /* ============================================
83
+ NAVIGATION SIDEBAR
84
+ ============================================ */
85
+ .nav-sidebar {
86
+ width: 240px;
87
+ background: var(--bg-primary);
88
+ border-right: 1px solid var(--border-light);
89
+ display: flex;
90
+ flex-direction: column;
91
+ position: fixed;
92
+ left: 0;
93
+ top: 0;
94
+ height: 100vh;
95
+ z-index: 1000;
96
+ box-shadow: var(--shadow-md);
97
+ overflow-y: auto;
98
+ }
99
+
100
+ .nav-sidebar-header {
101
+ position: relative;
102
+ padding: 2rem 1.5rem 1.5rem;
103
+ border-bottom: 1px solid var(--border-light);
104
+ }
105
+
106
+ .nav-sidebar-header h1 {
107
+ font-size: 1.5rem;
108
+ font-weight: 600;
109
+ margin: 0 0 0.5rem 0;
110
+ color: var(--text-primary);
111
+ letter-spacing: -0.01em;
112
+ line-height: 1.3;
113
+ }
114
+
115
+ .nav-subtitle {
116
+ font-size: 0.875rem;
117
+ color: var(--text-secondary);
118
+ margin: 0;
119
+ line-height: 1.4;
120
+ }
121
+
122
+ .nav-collapse-toggle {
123
+ position: absolute;
124
+ top: 0.5rem;
125
+ right: 0.5rem;
126
+ background: transparent;
127
+ border: none;
128
+ color: var(--text-secondary);
129
+ font-size: 0.9rem;
130
+ cursor: pointer;
131
+ padding: 0.25rem 0.5rem;
132
+ transition: all var(--transition-base);
133
+ outline: none;
134
+ z-index: 10;
135
+ }
136
+
137
+ .nav-collapse-toggle:hover {
138
+ color: var(--text-primary);
139
+ background: var(--bg-secondary);
140
+ }
141
+
142
+ .nav-collapse-toggle:focus {
143
+ outline: 2px solid var(--accent-blue);
144
+ outline-offset: 2px;
145
+ }
146
+
147
+ .nav-sidebar.collapsed {
148
+ width: 60px;
149
+ }
150
+
151
+ .nav-sidebar.collapsed .nav-sidebar-header h1,
152
+ .nav-sidebar.collapsed .nav-subtitle {
153
+ display: none;
154
+ }
155
+
156
+ .nav-tabs {
157
+ flex: 1;
158
+ padding: 0;
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 0;
162
+ }
163
+
164
+ .nav-tab {
165
+ background: transparent;
166
+ border: none;
167
+ border-left: 3px solid transparent;
168
+ color: var(--text-secondary);
169
+ padding: 1rem 1.5rem;
170
+ cursor: pointer;
171
+ font-size: 0.95rem;
172
+ text-align: left;
173
+ transition: all 0.15s ease;
174
+ white-space: nowrap;
175
+ position: relative;
176
+ overflow: hidden;
177
+ font-weight: 500;
178
+ font-family: var(--font-primary);
179
+ position: relative;
180
+ margin: 0;
181
+ }
182
+
183
+ .nav-tab:hover {
184
+ background: var(--bg-secondary);
185
+ color: var(--text-primary);
186
+ border-left-color: var(--border-medium);
187
+ }
188
+
189
+ .nav-tab:active {
190
+ background: var(--bg-tertiary, rgba(255, 255, 255, 0.08));
191
+ transition: all 0.05s ease;
192
+ }
193
+
194
+ .nav-tab:focus {
195
+ outline: none;
196
+ }
197
+
198
+ .nav-tab:focus-visible {
199
+ outline: 2px solid var(--accent-blue);
200
+ outline-offset: -2px;
201
+ }
202
+
203
+ .nav-tab.active {
204
+ background: linear-gradient(90deg, rgba(59, 130, 246, 0.15) 0%, transparent 100%);
205
+ border-left-color: var(--accent-blue);
206
+ color: var(--accent-blue);
207
+ font-weight: 600;
208
+ box-shadow: inset 0 0 20px rgba(59, 130, 246, 0.05);
209
+ }
210
+
211
+ .nav-tab.active::before {
212
+ content: '';
213
+ position: absolute;
214
+ left: 0;
215
+ top: 0;
216
+ bottom: 0;
217
+ width: 3px;
218
+ background: var(--accent-blue);
219
+ box-shadow: 0 0 8px rgba(59, 130, 246, 0.5);
220
+ }
221
+
222
+ .nav-sidebar-footer {
223
+ padding: 1.5rem;
224
+ border-top: 1px solid var(--border-light);
225
+ margin-top: auto;
226
+ }
227
+
228
+ .nav-links {
229
+ display: flex;
230
+ flex-direction: column;
231
+ gap: 0.75rem;
232
+ }
233
+
234
+ .nav-links a {
235
+ color: var(--text-secondary);
236
+ text-decoration: none;
237
+ font-size: 0.875rem;
238
+ transition: color var(--transition-base);
239
+ font-weight: 500;
240
+ }
241
+
242
+ .nav-links a:hover {
243
+ color: var(--accent-blue);
244
+ text-decoration: underline;
245
+ }
246
+
247
+ /* ============================================
248
+ APP MAIN CONTENT
249
+ ============================================ */
250
+ .app-main {
251
+ flex: 1;
252
+ margin-left: 240px;
253
+ display: flex;
254
+ flex-direction: column;
255
+ min-height: 100vh;
256
+ transition: margin-left var(--transition-base);
257
+ width: calc(100% - 240px);
258
+ max-width: none;
259
+ box-sizing: border-box;
260
+ overflow-x: hidden;
261
+ }
262
+
263
+ .nav-sidebar.collapsed ~ .app-main {
264
+ margin-left: 60px;
265
+ width: calc(100% - 60px);
266
+ max-width: none;
267
+ }
268
+
269
+ .app-content {
270
+ flex: 1;
271
+ overflow: auto;
272
  }
273
 
274
  /* ============================================
275
  HEADER
276
  ============================================ */
277
  .App-header {
278
+ background: var(--bg-primary);
279
  color: var(--text-primary);
280
  padding: 1rem 2rem;
281
+ border-bottom: 1px solid var(--border-light);
282
+ box-shadow: var(--shadow-sm);
283
+ width: 100%;
284
+ box-sizing: border-box;
285
+ position: sticky;
286
+ top: 0;
287
+ z-index: 100;
288
+ }
289
+
290
+ .header-content {
291
+ display: flex;
292
+ justify-content: flex-end;
293
+ align-items: center;
294
+ gap: 1rem;
295
+ width: 100%;
296
+ }
297
+
298
+ .header-right {
299
+ display: flex;
300
+ align-items: center;
301
+ gap: 0.75rem;
302
+ flex-wrap: wrap;
303
+ flex-shrink: 0;
304
+ }
305
+
306
+ .App-header h1 {
307
+ margin: 0;
308
+ font-size: 1.5rem;
309
+ font-weight: 600;
310
+ letter-spacing: -0.01em;
311
+ line-height: 1.3;
312
+ color: #ffffff;
313
+ }
314
+
315
+ .header-nav {
316
+ margin-top: 0.75rem;
317
+ display: flex;
318
+ gap: 0.5rem;
319
+ flex-wrap: wrap;
320
+ align-items: center;
321
+ }
322
+
323
+ .nav-tab {
324
+ background: transparent;
325
+ border: 1px solid var(--border-light);
326
+ border-radius: 0;
327
+ padding: 0.5rem 1rem;
328
+ color: var(--text-primary);
329
+ cursor: pointer;
330
+ font-size: 0.9rem;
331
+ font-weight: 500;
332
+ white-space: nowrap;
333
+ transition: all var(--transition-base);
334
+ border-bottom: 2px solid transparent;
335
+ font-family: var(--font-primary);
336
+ }
337
+
338
+ .nav-tab:hover {
339
+ background: var(--bg-secondary);
340
+ color: var(--text-primary);
341
+ }
342
+
343
+ .nav-tab.active {
344
+ background: var(--bg-secondary);
345
+ border-bottom-color: var(--accent-blue);
346
+ color: var(--accent-blue);
347
+ }
348
+
349
+ .header-links {
350
+ margin-top: 0.5rem;
351
+ font-size: 0.85rem;
352
+ opacity: 0.9;
353
+ display: flex;
354
+ gap: 1rem;
355
+ flex-wrap: wrap;
356
+ align-items: center;
357
+ }
358
+
359
+ .App-header a {
360
+ color: #64b5f6;
361
+ text-decoration: none;
362
+ font-weight: 500;
363
+ transition: all var(--transition-base);
364
+ white-space: nowrap;
365
+ }
366
+
367
+ .App-header a:hover {
368
+ color: #90caf9;
369
+ }
370
+
371
+ .header-author {
372
+ opacity: 0.7;
373
+ white-space: nowrap;
374
+ font-size: 0.85rem;
375
+ }
376
+
377
+ .header-action-btn {
378
+ background: rgba(255, 255, 255, 0.15);
379
+ border: 1px solid rgba(255, 255, 255, 0.3);
380
+ border-radius: 0;
381
+ padding: 0.5rem 1rem;
382
+ color: #ffffff;
383
+ cursor: pointer;
384
+ font-size: 0.85rem;
385
+ font-weight: 500;
386
+ white-space: nowrap;
387
+ transition: all var(--transition-base);
388
+ flex-shrink: 0;
389
+ font-family: var(--font-primary);
390
+ }
391
+
392
+ .header-action-btn:hover {
393
+ background: rgba(255, 255, 255, 0.25);
394
+ border-color: rgba(255, 255, 255, 0.4);
395
+ }
396
+
397
+ .header-refresh-btn {
398
+ background: rgba(255, 255, 255, 0.15);
399
+ border: 1px solid rgba(255, 255, 255, 0.3);
400
+ border-radius: 0;
401
+ width: 32px;
402
+ height: 32px;
403
+ cursor: pointer;
404
+ display: flex;
405
+ align-items: center;
406
+ justify-content: center;
407
+ font-size: 16px;
408
+ transition: all var(--transition-base);
409
+ flex-shrink: 0;
410
+ color: #ffffff;
411
+ }
412
+
413
+ .header-refresh-btn:hover {
414
+ background: rgba(255, 255, 255, 0.25);
415
+ border-color: rgba(255, 255, 255, 0.4);
416
+ }
417
+
418
+ .stats {
419
+ display: flex;
420
+ gap: 0.75rem;
421
+ font-size: 0.85rem;
422
+ flex-wrap: wrap;
423
+ }
424
+
425
+ .stats span {
426
+ padding: 0.5rem 0.875rem;
427
+ background: rgba(255, 255, 255, 0.1);
428
+ border-radius: 0;
429
+ border: 1px solid rgba(255, 255, 255, 0.2);
430
+ font-weight: 500;
431
+ transition: all var(--transition-base);
432
+ }
433
+
434
+ .stats span:hover {
435
+ background: rgba(255, 255, 255, 0.15);
436
+ }
437
+
438
+ /* ============================================
439
+ VISUALIZATION LAYOUT
440
+ ============================================ */
441
+ .visualization-layout {
442
+ display: flex;
443
+ flex-direction: column;
444
+ height: calc(100vh - 60px);
445
  width: 100%;
446
+ }
447
+
448
+ /* Ultra-Minimal Control Bar */
449
+ .control-bar {
450
+ background: var(--bg-primary);
451
+ border-bottom: 1px solid var(--border-light);
452
+ box-shadow: var(--shadow-sm);
453
+ z-index: 50;
454
+ position: sticky;
455
+ top: 0;
456
+ }
457
+
458
+ .control-bar-content {
459
+ display: flex;
460
+ align-items: center;
461
+ justify-content: space-between;
462
+ padding: 0.5rem 1rem;
463
+ gap: 1rem;
464
+ max-width: 100%;
465
  box-sizing: border-box;
466
+ overflow-x: auto;
467
+ overflow-y: hidden;
468
+ }
469
+
470
+ .control-bar-content::-webkit-scrollbar {
471
+ height: 4px;
472
+ }
473
+
474
+ .control-bar-content::-webkit-scrollbar-track {
475
+ background: transparent;
476
+ }
477
+
478
+ .control-bar-content::-webkit-scrollbar-thumb {
479
+ background: var(--border-medium);
480
+ border-radius: 2px;
481
+ }
482
+
483
+ .control-bar-content::-webkit-scrollbar-thumb:hover {
484
+ background: var(--border-dark);
485
+ }
486
+
487
+ .control-bar-left {
488
+ display: flex;
489
+ align-items: center;
490
+ gap: 0.5rem;
491
+ flex-shrink: 0;
492
+ }
493
+
494
+ .control-back-btn {
495
+ background: transparent;
496
+ border: none;
497
+ color: var(--text-secondary);
498
+ font-size: 1rem;
499
+ cursor: pointer;
500
+ padding: 0.25rem 0.5rem;
501
+ transition: color var(--transition-base);
502
+ }
503
+
504
+ .control-back-btn:hover {
505
+ color: var(--text-primary);
506
+ }
507
+
508
+ .control-bar-title {
509
+ font-size: 0.9rem;
510
+ font-weight: 500;
511
+ color: var(--text-primary);
512
+ }
513
+
514
+ .control-bar-center {
515
+ display: flex;
516
+ align-items: center;
517
+ gap: 1rem;
518
+ flex: 1;
519
+ justify-content: center;
520
+ flex-wrap: wrap;
521
+ min-width: 0;
522
+ }
523
+
524
+ .control-group {
525
+ display: flex;
526
+ align-items: center;
527
+ gap: 0.5rem;
528
+ flex-shrink: 0;
529
+ }
530
+
531
+ .control-group.control-filters {
532
+ position: relative;
533
+ }
534
+
535
+ .control-label {
536
+ font-size: 0.85rem;
537
+ color: var(--text-secondary);
538
+ white-space: nowrap;
539
+ }
540
+
541
+ .control-toggle-btn {
542
+ padding: 0.35rem 0.75rem;
543
+ border: 1px solid var(--border-medium);
544
+ background: var(--bg-primary);
545
+ color: var(--text-secondary);
546
+ font-size: 0.85rem;
547
+ cursor: pointer;
548
+ transition: all var(--transition-base);
549
+ font-family: var(--font-primary);
550
+ }
551
+
552
+ .control-toggle-btn:first-child {
553
+ border-right: none;
554
+ }
555
+
556
+ .control-toggle-btn.active {
557
+ background: var(--accent-blue);
558
+ color: white;
559
+ border-color: var(--accent-blue);
560
+ }
561
+
562
+ .control-toggle-btn:hover:not(.active) {
563
+ background: var(--bg-secondary);
564
+ color: var(--text-primary);
565
+ }
566
+
567
+ .control-select {
568
+ padding: 0.35rem 0.5rem;
569
+ border: 1px solid var(--border-medium);
570
+ background: var(--bg-primary);
571
+ color: var(--text-primary);
572
+ font-size: 0.85rem;
573
+ font-family: var(--font-primary);
574
+ cursor: pointer;
575
+ min-width: 130px;
576
+ transition: all var(--transition-base);
577
+ outline: none;
578
+ }
579
+
580
+ .control-select:hover {
581
+ border-color: var(--border-dark);
582
+ }
583
+
584
+ .control-select:focus {
585
+ border-color: var(--accent-blue);
586
+ box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1);
587
+ }
588
+
589
+ .control-select:focus {
590
+ outline: none;
591
+ border-color: var(--accent-blue);
592
+ }
593
+
594
+ .control-select-small {
595
+ min-width: 90px;
596
+ }
597
+
598
+ .control-icon {
599
+ color: var(--text-tertiary);
600
+ flex-shrink: 0;
601
+ }
602
+
603
+ .control-divider {
604
+ width: 1px;
605
+ height: 20px;
606
+ background: var(--border-light);
607
+ margin: 0 0.25rem;
608
+ }
609
+
610
+ .control-stats {
611
+ display: flex;
612
+ align-items: center;
613
+ gap: 6px;
614
+ padding: 0.35rem 0.75rem;
615
+ background: var(--bg-secondary);
616
+ border: 1px solid var(--border-light);
617
+ font-size: 0.8rem;
618
+ color: var(--text-secondary);
619
+ }
620
+
621
+ .control-stats-text {
622
+ font-family: var(--font-mono);
623
+ font-size: 0.75rem;
624
+ color: var(--text-primary);
625
+ font-weight: 500;
626
+ }
627
+
628
+ .control-btn {
629
+ padding: 0.35rem 0.75rem;
630
+ border: 1px solid var(--border-medium);
631
+ background: var(--bg-primary);
632
+ color: var(--text-primary);
633
+ font-size: 0.85rem;
634
+ font-family: var(--font-primary);
635
+ cursor: pointer;
636
+ transition: all var(--transition-base);
637
+ position: relative;
638
+ display: inline-flex;
639
+ align-items: center;
640
+ justify-content: center;
641
+ }
642
+
643
+ .control-btn:hover {
644
+ background: var(--bg-secondary);
645
+ border-color: var(--accent-blue);
646
+ }
647
+
648
+ .control-btn.has-filters {
649
+ border-color: var(--accent-blue);
650
+ background: var(--bg-secondary);
651
+ font-weight: 500;
652
+ }
653
+
654
+ .control-btn.has-filters:hover {
655
+ background: var(--bg-tertiary);
656
+ border-color: var(--accent-blue-hover);
657
+ }
658
+
659
+ .control-btn:active {
660
+ opacity: 0.9;
661
+ }
662
+
663
+ .filter-badge {
664
+ display: inline-flex;
665
+ align-items: center;
666
+ justify-content: center;
667
+ background: var(--accent-blue);
668
+ color: white;
669
+ font-size: 0.7rem;
670
+ font-weight: 600;
671
+ padding: 0.2rem 0.5rem;
672
+ margin-left: 0.5rem;
673
+ min-width: 20px;
674
+ height: 20px;
675
+ line-height: 1;
676
+ }
677
+
678
+ .control-filters {
679
+ position: relative;
680
+ }
681
+
682
+ .filters-dropdown {
683
+ position: absolute;
684
+ top: calc(100% + 0.5rem);
685
+ left: 0;
686
+ background: var(--bg-elevated);
687
+ border: 1px solid var(--border-medium);
688
+ box-shadow: var(--shadow-lg);
689
+ padding: 1.25rem;
690
+ min-width: 280px;
691
+ max-width: 320px;
692
+ z-index: 1000;
693
+ display: flex;
694
+ flex-direction: column;
695
+ gap: 1.25rem;
696
+ }
697
+
698
+ [data-theme="dark"] .filters-dropdown {
699
+ background: var(--bg-elevated);
700
+ border-color: var(--border-dark);
701
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
702
+ }
703
+
704
+ .filter-dropdown-item {
705
+ display: flex;
706
+ flex-direction: column;
707
+ gap: 0.5rem;
708
+ }
709
+
710
+ .filter-dropdown-item label {
711
+ font-size: 0.8rem;
712
+ font-weight: 500;
713
+ color: var(--text-secondary);
714
+ text-transform: uppercase;
715
+ letter-spacing: 0.5px;
716
+ margin-bottom: 0.25rem;
717
+ user-select: none;
718
+ }
719
+
720
+ .filter-dropdown-item label[for^="filter-base"] {
721
+ flex-direction: row;
722
+ align-items: center;
723
+ gap: 0.75rem;
724
+ cursor: pointer;
725
+ text-transform: none;
726
+ font-weight: 400;
727
+ font-size: 0.875rem;
728
+ color: var(--text-primary);
729
+ margin-bottom: 0;
730
+ }
731
+
732
+ .filter-dropdown-item input[type="checkbox"] {
733
+ cursor: pointer;
734
+ width: 18px;
735
+ height: 18px;
736
+ margin: 0;
737
+ accent-color: var(--accent-blue);
738
+ }
739
+
740
+ .filter-number-input {
741
+ padding: 0.5rem 0.75rem;
742
+ border: 1px solid var(--border-medium);
743
+ background: var(--bg-primary);
744
+ color: var(--text-primary);
745
+ font-size: 0.875rem;
746
+ font-family: var(--font-primary);
747
+ width: 100%;
748
+ transition: all var(--transition-base);
749
+ outline: none;
750
+ }
751
+
752
+ .filter-number-input:hover {
753
+ border-color: var(--border-dark);
754
+ background: var(--bg-secondary);
755
+ }
756
+
757
+ .filter-number-input:focus {
758
+ outline: none;
759
+ border-color: var(--accent-blue);
760
+ box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
761
+ background: var(--bg-primary);
762
+ }
763
+
764
+ .filter-number-input::placeholder {
765
+ color: var(--text-tertiary);
766
+ font-style: italic;
767
+ }
768
+
769
+ .filter-reset-section {
770
+ margin-top: 0.5rem;
771
+ padding-top: 1rem;
772
+ border-top: 1px solid var(--border-light);
773
+ }
774
+
775
+ .filter-reset-btn {
776
+ padding: 0.5rem 1rem;
777
+ border: 1px solid var(--border-medium);
778
+ background: var(--bg-secondary);
779
+ color: var(--text-primary);
780
+ font-size: 0.875rem;
781
+ font-weight: 500;
782
+ font-family: var(--font-primary);
783
+ cursor: pointer;
784
+ transition: all var(--transition-base);
785
+ width: 100%;
786
+ }
787
+
788
+ .filter-reset-btn:hover {
789
+ background: var(--bg-tertiary);
790
+ border-color: var(--accent-blue);
791
+ color: var(--accent-blue);
792
+ }
793
+
794
+ .filter-reset-btn:active {
795
+ opacity: 0.9;
796
+ }
797
+
798
+ .control-bar-right {
799
+ display: flex;
800
+ align-items: center;
801
+ gap: 0.5rem;
802
+ flex-shrink: 0;
803
+ }
804
+
805
+ .semantic-search-toggle {
806
+ padding: 0.5rem 0.75rem;
807
+ background: var(--bg-secondary);
808
+ border: 1px solid var(--border-medium);
809
+ font-size: 0.85rem;
810
+ font-weight: 500;
811
+ color: var(--text-primary);
812
+ cursor: pointer;
813
+ transition: all 0.2s ease;
814
+ white-space: nowrap;
815
+ }
816
+
817
+ .semantic-search-toggle:hover {
818
+ background: var(--bg-tertiary);
819
+ border-color: var(--accent-blue);
820
+ }
821
+
822
+ .semantic-search-toggle.active {
823
+ background: var(--accent-blue);
824
+ border-color: var(--accent-blue);
825
+ color: #ffffff;
826
+ }
827
+
828
+ .semantic-search-panel {
829
+ position: fixed;
830
+ top: 50%;
831
+ left: 50%;
832
+ transform: translate(-50%, -50%);
833
+ width: 90%;
834
+ max-width: 700px;
835
+ max-height: 80vh;
836
+ background: var(--bg-elevated);
837
+ border: 1px solid var(--border-medium);
838
+ box-shadow: var(--shadow-lg);
839
+ z-index: 2000;
840
+ display: flex;
841
+ flex-direction: column;
842
+ overflow: hidden;
843
+ }
844
+
845
+ [data-theme="dark"] .semantic-search-panel {
846
+ background: rgba(20, 20, 20, 0.98);
847
+ border-color: rgba(255, 255, 255, 0.2);
848
+ }
849
+
850
+ .semantic-search-panel-header {
851
+ display: flex;
852
+ align-items: center;
853
+ justify-content: space-between;
854
+ padding: 1rem 1.5rem;
855
+ border-bottom: 1px solid var(--border-light);
856
+ }
857
+
858
+ [data-theme="dark"] .semantic-search-panel-header {
859
+ border-bottom-color: rgba(255, 255, 255, 0.1);
860
+ }
861
+
862
+ .semantic-search-panel-header h3 {
863
+ margin: 0;
864
+ font-size: 1.1rem;
865
+ font-weight: 600;
866
+ color: var(--text-primary);
867
+ }
868
+
869
+ .semantic-search-panel .close-btn {
870
+ background: none;
871
+ border: none;
872
+ font-size: 1.5rem;
873
+ color: var(--text-secondary);
874
+ cursor: pointer;
875
+ padding: 0;
876
+ width: 2rem;
877
+ height: 2rem;
878
+ display: flex;
879
+ align-items: center;
880
+ justify-content: center;
881
+ transition: background-color 0.2s ease, color 0.2s ease;
882
+ }
883
+
884
+ .semantic-search-panel .close-btn:hover {
885
+ background: var(--bg-tertiary);
886
+ color: var(--text-primary);
887
+ }
888
+
889
+ .semantic-search-panel > div:last-child {
890
+ padding: 1.5rem;
891
+ overflow-y: auto;
892
+ }
893
+
894
+ .control-search {
895
+ display: flex;
896
+ align-items: center;
897
+ gap: 0.5rem;
898
+ border: 1px solid var(--border-medium);
899
+ padding: 0.35rem 0.75rem;
900
+ background: var(--bg-primary);
901
+ transition: all var(--transition-base);
902
+ position: relative;
903
+ width: 100%;
904
+ }
905
+
906
+ .control-search:focus-within {
907
+ border-color: var(--accent-blue);
908
+ box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1);
909
+ }
910
+
911
+ .search-icon {
912
+ font-size: 0.9rem;
913
+ color: var(--text-secondary);
914
+ }
915
+
916
+ .control-search-input {
917
+ border: none;
918
+ background: transparent;
919
+ color: var(--text-primary);
920
+ font-size: 0.85rem;
921
+ font-family: var(--font-primary);
922
+ width: 150px;
923
+ min-width: 100px;
924
+ max-width: 200px;
925
+ outline: none;
926
+ }
927
+
928
+ .control-search-input:focus {
929
+ outline: none;
930
+ }
931
+
932
+ .control-search-input::placeholder {
933
+ color: var(--text-tertiary);
934
+ }
935
+
936
+ /* Legacy filters-top-bar for backwards compatibility */
937
+ .filters-top-bar {
938
+ background: var(--bg-primary);
939
+ border-bottom: 1px solid var(--border-light);
940
+ box-shadow: var(--shadow-sm);
941
+ z-index: 50;
942
+ position: sticky;
943
+ top: 0;
944
+ }
945
+
946
+ .filters-bar-content {
947
+ display: flex;
948
+ align-items: center;
949
+ gap: 0.75rem;
950
+ padding: 0.5rem 1rem;
951
+ flex-wrap: wrap;
952
+ max-width: 100%;
953
+ box-sizing: border-box;
954
+ }
955
+
956
+ .filter-item {
957
+ display: flex;
958
+ align-items: center;
959
+ gap: 0.5rem;
960
+ flex-shrink: 0;
961
+ }
962
+
963
+ .filter-item-range {
964
+ gap: 0.5rem;
965
+ min-width: 140px;
966
+ }
967
+
968
+ .filter-item-count {
969
+ margin-left: auto;
970
+ gap: 0.25rem;
971
+ }
972
+
973
+ .filter-item-averages {
974
+ gap: 1rem;
975
+ margin-left: 0.5rem;
976
+ padding-left: 1rem;
977
+ border-left: 1px solid var(--border-light);
978
+ }
979
+
980
+ .average-item {
981
+ display: flex;
982
+ flex-direction: column;
983
+ gap: 0.15rem;
984
+ align-items: flex-end;
985
+ }
986
+
987
+ .average-label {
988
+ font-size: 0.7rem;
989
+ color: var(--text-tertiary);
990
+ text-transform: uppercase;
991
+ letter-spacing: 0.05em;
992
+ }
993
+
994
+ .average-value {
995
+ font-size: 0.85rem;
996
+ font-weight: 600;
997
+ color: var(--text-primary);
998
+ }
999
+
1000
+ .filter-item-actions {
1001
+ gap: 0.5rem;
1002
+ margin-left: 0.5rem;
1003
+ }
1004
+
1005
+ .filter-action-btn {
1006
+ padding: 0.35rem 0.6rem;
1007
+ border: 1px solid var(--border-medium);
1008
+ border-radius: 0;
1009
+ background: var(--bg-primary);
1010
+ color: var(--text-primary);
1011
+ font-size: 0.8rem;
1012
+ font-family: var(--font-primary);
1013
+ cursor: pointer;
1014
+ transition: all var(--transition-base);
1015
+ white-space: nowrap;
1016
+ }
1017
+
1018
+ .filter-action-btn:hover {
1019
+ background: var(--bg-secondary);
1020
+ border-color: var(--accent-blue);
1021
+ color: var(--accent-blue);
1022
+ }
1023
+
1024
+ .filter-refresh-btn {
1025
+ padding: 0.35rem 0.5rem;
1026
+ font-size: 0.9rem;
1027
+ }
1028
+
1029
+ .filter-group {
1030
+ display: flex;
1031
+ align-items: center;
1032
+ gap: 0.5rem;
1033
+ flex-shrink: 0;
1034
+ }
1035
+
1036
+ .filter-group:has(.filter-label-compact) {
1037
+ flex-direction: column;
1038
+ align-items: flex-start;
1039
+ gap: 0.25rem;
1040
+ }
1041
+
1042
+ .filter-input {
1043
+ padding: 0.5rem 0.75rem;
1044
+ border: 1px solid var(--border-medium);
1045
+ border-radius: 0;
1046
+ font-size: 0.875rem;
1047
+ font-family: var(--font-primary);
1048
+ background: var(--bg-primary);
1049
+ color: var(--text-primary);
1050
+ min-width: 180px;
1051
+ max-width: 100%;
1052
+ transition: all var(--transition-base);
1053
+ }
1054
+
1055
+ .filter-input-compact {
1056
+ padding: 0.4rem 0.6rem;
1057
+ border: 1px solid var(--border-medium);
1058
+ border-radius: 0;
1059
+ font-size: 0.8rem;
1060
+ font-family: var(--font-primary);
1061
+ background: var(--bg-primary);
1062
+ color: var(--text-primary);
1063
+ width: 150px;
1064
+ transition: all var(--transition-base);
1065
+ }
1066
+
1067
+ .filter-input-compact:focus {
1068
+ outline: none;
1069
+ border-color: var(--accent-blue);
1070
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
1071
+ }
1072
+
1073
+ .filter-input:focus {
1074
+ outline: none;
1075
+ border-color: var(--accent-blue);
1076
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
1077
+ }
1078
+
1079
+ .filter-label-inline {
1080
+ display: flex;
1081
+ align-items: center;
1082
+ gap: 0.5rem;
1083
+ font-size: 0.875rem;
1084
+ color: var(--text-secondary);
1085
+ white-space: nowrap;
1086
+ }
1087
+
1088
+ .filter-label-inline span:first-child {
1089
+ min-width: 70px;
1090
+ font-weight: 500;
1091
+ }
1092
+
1093
+ .filter-label-text {
1094
+ min-width: 90px;
1095
+ font-weight: 500;
1096
+ color: var(--text-secondary);
1097
+ font-size: 0.875rem;
1098
+ }
1099
+
1100
+ .filter-label-compact {
1101
+ display: block;
1102
+ font-size: 0.75rem;
1103
+ font-weight: 500;
1104
+ color: var(--text-secondary);
1105
+ margin-bottom: 0.25rem;
1106
+ text-transform: uppercase;
1107
+ letter-spacing: 0.05em;
1108
+ }
1109
+
1110
+ .filter-range {
1111
+ width: 100px;
1112
+ height: 4px;
1113
+ -webkit-appearance: none;
1114
+ appearance: none;
1115
+ background: var(--bg-tertiary);
1116
+ border-radius: 0;
1117
+ outline: none;
1118
+ cursor: pointer;
1119
+ flex-shrink: 1;
1120
+ }
1121
+
1122
+ .filter-range-compact {
1123
+ width: 80px;
1124
+ height: 3px;
1125
+ -webkit-appearance: none;
1126
+ appearance: none;
1127
+ background: var(--bg-tertiary);
1128
+ border-radius: 0;
1129
+ outline: none;
1130
+ cursor: pointer;
1131
+ flex-shrink: 1;
1132
+ }
1133
+
1134
+ .filter-range-compact::-webkit-slider-thumb {
1135
+ -webkit-appearance: none;
1136
+ width: 12px;
1137
+ height: 12px;
1138
+ border-radius: 0;
1139
+ background: var(--accent-blue);
1140
+ cursor: pointer;
1141
+ }
1142
+
1143
+ .filter-range-compact::-moz-range-thumb {
1144
+ width: 12px;
1145
+ height: 12px;
1146
+ border-radius: 0;
1147
+ background: var(--accent-blue);
1148
+ cursor: pointer;
1149
+ border: none;
1150
+ }
1151
+
1152
+ .filter-range::-webkit-slider-thumb {
1153
+ -webkit-appearance: none;
1154
+ width: 14px;
1155
+ height: 14px;
1156
+ border-radius: 0;
1157
+ background: var(--accent-blue);
1158
+ cursor: pointer;
1159
+ }
1160
+
1161
+ .filter-range::-moz-range-thumb {
1162
+ width: 14px;
1163
+ height: 14px;
1164
+ border-radius: 0;
1165
+ background: var(--accent-blue);
1166
+ cursor: pointer;
1167
+ border: none;
1168
+ }
1169
+
1170
+ .filter-value-inline {
1171
+ min-width: 50px;
1172
+ font-size: 0.875rem;
1173
+ font-weight: 500;
1174
+ color: var(--text-primary);
1175
+ text-align: right;
1176
+ flex-shrink: 0;
1177
+ }
1178
+
1179
+ .filter-select {
1180
+ padding: 0.5rem 0.75rem;
1181
+ border: 1px solid var(--border-medium);
1182
+ border-radius: 0;
1183
+ font-size: 0.875rem;
1184
+ font-family: var(--font-primary);
1185
+ background: var(--bg-primary);
1186
+ color: var(--text-primary);
1187
+ cursor: pointer;
1188
+ transition: all var(--transition-base);
1189
+ min-width: 110px;
1190
+ max-width: 100%;
1191
+ }
1192
+
1193
+ .filter-select-compact {
1194
+ padding: 0.4rem 0.6rem;
1195
+ border: 1px solid var(--border-medium);
1196
+ border-radius: 0;
1197
+ font-size: 0.8rem;
1198
+ font-family: var(--font-primary);
1199
+ background: var(--bg-primary);
1200
+ color: var(--text-primary);
1201
+ cursor: pointer;
1202
+ transition: all var(--transition-base);
1203
+ min-width: 90px;
1204
+ max-width: 100%;
1205
+ }
1206
+
1207
+ .filter-select-compact:hover {
1208
+ border-color: var(--border-dark);
1209
+ }
1210
+
1211
+ .filter-select-compact:focus {
1212
+ outline: none;
1213
+ border-color: var(--accent-blue);
1214
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
1215
+ }
1216
+
1217
+ .filter-select:hover {
1218
+ border-color: var(--border-dark);
1219
+ }
1220
+
1221
+ .filter-select:focus {
1222
+ outline: none;
1223
+ border-color: var(--accent-blue);
1224
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
1225
+ }
1226
+
1227
+ .filter-checkbox-inline {
1228
+ display: flex;
1229
+ align-items: center;
1230
+ gap: 0.5rem;
1231
+ font-size: 0.875rem;
1232
+ color: var(--text-primary);
1233
+ cursor: pointer;
1234
+ }
1235
+
1236
+ .filter-checkbox-compact {
1237
+ display: flex;
1238
+ align-items: center;
1239
+ gap: 0.4rem;
1240
+ font-size: 0.8rem;
1241
+ color: var(--text-primary);
1242
+ cursor: pointer;
1243
+ white-space: nowrap;
1244
  }
1245
+
1246
+ .filter-checkbox-compact input[type="checkbox"] {
1247
  margin: 0;
1248
+ cursor: pointer;
 
 
 
 
1249
  }
1250
+
1251
+ .filter-item-label {
1252
+ font-size: 0.75rem;
 
1253
  font-weight: 500;
1254
+ color: var(--text-secondary);
1255
+ white-space: nowrap;
1256
+ min-width: 60px;
1257
  }
1258
+
1259
+ .filter-item-value {
1260
+ font-size: 0.75rem;
1261
+ font-weight: 500;
1262
+ color: var(--text-primary);
1263
+ min-width: 45px;
1264
+ text-align: right;
1265
+ white-space: nowrap;
1266
  }
1267
+
1268
+ .model-count-compact {
 
 
1269
  font-size: 0.85rem;
1270
+ font-weight: 600;
1271
+ color: var(--text-primary);
1272
  }
1273
+
1274
+ .model-count-compact-separator {
1275
+ font-size: 0.75rem;
1276
+ color: var(--text-tertiary);
1277
+ margin: 0 0.15rem;
1278
+ }
1279
+
1280
+ .model-count-compact-secondary {
1281
+ font-size: 0.8rem;
1282
  font-weight: 500;
1283
+ color: var(--text-secondary);
1284
  }
1285
+
1286
+ .filter-checkbox-inline input[type="checkbox"] {
1287
+ margin: 0;
1288
+ cursor: pointer;
1289
  }
1290
+
1291
+ .model-count-inline {
1292
+ margin-left: auto;
 
 
1293
  display: flex;
1294
+ align-items: baseline;
1295
+ gap: 0.25rem;
1296
+ font-size: 0.875rem;
1297
  }
1298
+
1299
+ .model-count-inline .model-count-value {
1300
+ font-size: 1rem;
1301
+ font-weight: 600;
1302
+ color: var(--text-primary);
1303
+ }
1304
+
1305
+ .model-count-inline .model-count-label {
1306
+ font-size: 0.75rem;
1307
+ color: var(--text-secondary);
1308
  }
1309
 
1310
  .visualization {
1311
  flex: 1;
1312
+ padding: 0;
1313
  display: flex;
1314
  align-items: center;
1315
  justify-content: center;
1316
  background: var(--bg-primary);
1317
+ overflow: hidden;
1318
  position: relative;
1319
+ width: 100%;
1320
+ height: 100%;
1321
  }
1322
 
1323
  /* ============================================
1324
  SIDEBAR COMPONENTS
1325
  ============================================ */
1326
+ .sidebar-header {
1327
+ display: flex;
1328
+ justify-content: space-between;
1329
+ align-items: center;
1330
+ margin-bottom: 1.5rem;
1331
+ padding-bottom: 1rem;
1332
+ border-bottom: 1px solid var(--border-light);
1333
+ }
1334
+
1335
  .sidebar h2 {
1336
+ margin: 0;
1337
+ font-size: 1.5rem;
1338
  font-weight: 600;
1339
  letter-spacing: -0.01em;
1340
  color: var(--text-primary);
1341
  }
1342
 
1343
  .sidebar h3 {
1344
+ font-size: 1rem;
1345
  font-weight: 600;
1346
  margin: 0 0 0.875rem 0;
1347
  letter-spacing: -0.01em;
1348
  color: var(--text-primary);
1349
+ line-height: 1.3;
1350
  }
1351
 
1352
  .sidebar-section {
1353
  background: var(--bg-elevated);
1354
  border-radius: 0;
1355
  padding: 1.25rem;
1356
+ margin-bottom: 1.25rem;
1357
  border: 1px solid var(--border-light);
1358
  transition: all var(--transition-base);
1359
  }
 
1362
  border-color: var(--border-medium);
1363
  box-shadow: var(--shadow-sm);
1364
  }
1365
+
1366
+ .sidebar-section:last-child {
1367
+ margin-bottom: 0;
1368
+ }
1369
+
1370
+ .filter-badge {
1371
+ font-size: 0.75rem;
1372
+ background: var(--accent-primary);
1373
+ color: white;
1374
+ padding: 0.35rem 0.7rem;
1375
+ border-radius: 0;
1376
+ font-weight: 600;
1377
+ }
1378
+
1379
+ .model-count-display {
1380
+ background: var(--bg-tertiary);
1381
+ border: 1px solid var(--border-medium);
1382
+ font-size: 0.9rem;
1383
+ margin-bottom: 1.5rem;
1384
+ padding: 1rem;
1385
+ }
1386
+
1387
+ .model-count-main {
1388
+ display: flex;
1389
+ justify-content: space-between;
1390
+ align-items: center;
1391
+ margin-bottom: 0.5rem;
1392
+ }
1393
+
1394
+ .model-count-value {
1395
+ font-size: 1.1rem;
1396
+ font-weight: 600;
1397
+ color: var(--text-primary);
1398
+ }
1399
+
1400
+ .model-count-label {
1401
+ margin-left: 0.4rem;
1402
+ color: var(--text-secondary);
1403
+ }
1404
+
1405
+ .model-count-meta {
1406
+ font-size: 0.8rem;
1407
+ color: var(--text-secondary);
1408
+ margin-top: 0.25rem;
1409
+ }
1410
+
1411
+ .model-count-meta:last-child {
1412
+ font-size: 0.75rem;
1413
+ }
1414
+
1415
+ .filter-label-row {
1416
+ display: flex;
1417
+ justify-content: space-between;
1418
+ margin-bottom: 0.5rem;
1419
+ }
1420
+
1421
+ .filter-label {
1422
+ font-weight: 500;
1423
+ color: var(--text-primary);
1424
+ }
1425
+
1426
+ .filter-value {
1427
+ font-weight: 600;
1428
+ color: var(--accent-primary);
1429
+ }
1430
+
1431
+ .filter-range-labels {
1432
+ display: flex;
1433
+ justify-content: space-between;
1434
+ font-size: 0.7rem;
1435
+ color: var(--text-secondary);
1436
+ margin-top: 0.25rem;
1437
+ }
1438
 
1439
  /* ============================================
1440
  FORM ELEMENTS
 
1504
  transition: all var(--transition-base);
1505
  }
1506
 
1507
+ .sidebar input[type="range"]::-webkit-slider-thumb:hover {
1508
+ background: var(--accent-hover);
1509
+ box-shadow: var(--shadow-lg);
1510
+ }
 
1511
 
1512
  .sidebar input[type="range"]::-moz-range-thumb {
1513
  width: 18px;
 
1520
  transition: all var(--transition-base);
1521
  }
1522
 
1523
+ .sidebar input[type="range"]::-moz-range-thumb:hover {
1524
+ background: var(--accent-hover);
1525
+ box-shadow: var(--shadow-lg);
1526
+ }
 
1527
 
1528
  /* Checkbox */
1529
  .sidebar input[type="checkbox"] {
 
1533
  accent-color: var(--accent-primary);
1534
  margin-right: 0.5rem;
1535
  }
1536
+
1537
+ /* Form Groups */
1538
+ .form-group {
1539
+ margin-bottom: 1.25rem;
1540
+ }
1541
+
1542
+ .form-group:last-child {
1543
+ margin-bottom: 0;
1544
+ }
1545
+
1546
+ .form-label {
1547
+ display: block;
1548
+ font-size: 0.9rem;
1549
+ font-weight: 600;
1550
+ color: var(--text-primary);
1551
+ margin-bottom: 0.5rem;
1552
+ letter-spacing: -0.01em;
1553
+ }
1554
+
1555
+ .form-select {
1556
+ width: 100%;
1557
+ padding: 0.75rem 1rem;
1558
+ border: 2px solid var(--border-light);
1559
+ border-radius: 0;
1560
+ font-size: 0.9rem;
1561
+ font-family: var(--font-primary);
1562
+ background: var(--bg-primary);
1563
+ color: var(--text-primary);
1564
+ transition: all var(--transition-base);
1565
+ box-shadow: var(--shadow-sm);
1566
+ cursor: pointer;
1567
+ }
1568
+
1569
+ .form-select:hover {
1570
+ border-color: var(--border-medium);
1571
+ box-shadow: var(--shadow-md);
1572
+ background: var(--bg-secondary);
1573
+ }
1574
+
1575
+ .form-select:focus {
1576
+ outline: none;
1577
+ border-color: var(--accent-blue);
1578
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
1579
+ background: var(--bg-primary);
1580
+ }
1581
+
1582
+ /* Checkbox Labels */
1583
+ .checkbox-label {
1584
+ display: flex;
1585
+ align-items: center;
1586
+ gap: 0.75rem;
1587
+ padding: 0.75rem;
1588
+ margin-bottom: 0.75rem;
1589
+ background: var(--bg-secondary);
1590
+ border: 1px solid var(--border-light);
1591
+ border-radius: 0;
1592
+ cursor: pointer;
1593
+ transition: all var(--transition-base);
1594
+ font-size: 0.9rem;
1595
+ font-weight: 500;
1596
+ color: var(--text-primary);
1597
+ }
1598
+
1599
+ .checkbox-label:hover {
1600
+ background: var(--bg-tertiary);
1601
+ border-color: var(--border-medium);
1602
+ }
1603
+
1604
+ .checkbox-label input[type="checkbox"] {
1605
+ margin: 0;
1606
+ cursor: pointer;
1607
+ }
1608
+
1609
+ .checkbox-label span {
1610
+ flex: 1;
1611
+ user-select: none;
1612
+ }
1613
 
1614
  /* ============================================
1615
  BUTTONS
 
1635
  box-shadow: var(--shadow-md);
1636
  }
1637
 
1638
+ .btn-primary:hover:not(:disabled) {
1639
+ background: var(--accent-hover);
1640
+ box-shadow: var(--shadow-lg);
1641
+ }
 
1642
 
1643
  .btn-secondary {
1644
  background: var(--bg-tertiary);
 
1673
  align-items: center;
1674
  gap: 0.5rem;
1675
  padding: 0.4rem 0.75rem;
1676
+ background: var(--bg-tertiary, #f5f5f5);
1677
+ color: var(--text-primary, #1a1a1a);
1678
  border-radius: 0;
1679
  font-size: 0.8rem;
1680
  font-weight: 500;
1681
+ border: 1px solid var(--border-medium, #d0d0d0);
1682
  cursor: pointer;
1683
  transition: all var(--transition-base);
1684
  }
1685
 
1686
+ .filter-chip:hover {
1687
+ background: var(--bg-secondary, #fafafa);
1688
+ box-shadow: var(--shadow-sm);
1689
+ border-color: var(--accent-blue, #4a90e2);
1690
+ }
1691
 
1692
  .filter-chip.active {
1693
+ background: var(--accent-blue, #4a90e2);
1694
+ color: #ffffff;
1695
+ border-color: var(--accent-blue, #4a90e2);
1696
  }
1697
 
1698
  .filter-chip .remove {
 
1876
  position: absolute;
1877
  cursor: pointer;
1878
  inset: 0;
1879
+ background-color: var(--border-medium, #d0d0d0);
1880
  border-radius: 0;
1881
  transition: background-color var(--transition-slow);
1882
  }
1883
+
1884
+ [data-theme="dark"] .label-toggle-slider {
1885
+ background-color: #555;
1886
+ }
1887
 
1888
  .label-toggle-slider:before {
1889
  position: absolute;
 
1920
  transition: all var(--transition-base);
1921
  }
1922
 
1923
+ .theme-toggle:hover {
1924
+ background: var(--bg-tertiary);
1925
+ border-color: var(--accent-blue);
1926
+ }
1927
 
1928
  /* ============================================
1929
  ZOOM CONTROLS
 
1968
  transition: all var(--transition-base);
1969
  }
1970
 
1971
+ .zoom-slider::-webkit-slider-thumb:hover {
1972
+ box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2);
1973
+ }
 
1974
 
1975
  .zoom-slider::-moz-range-thumb {
1976
  width: 16px;
 
1982
  transition: all var(--transition-base);
1983
  }
1984
 
1985
+ .zoom-slider::-moz-range-thumb:hover {
1986
+ box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2);
1987
+ }
 
1988
 
1989
  .zoom-slider:disabled {
1990
  opacity: 0.5;
 
2031
 
2032
  .loading::after {
2033
  content: '';
2034
+ width: 16px;
2035
+ height: 16px;
2036
+ border: 2px solid var(--border-light);
2037
  border-top-color: var(--accent-primary);
2038
+ border-radius: 50%;
2039
  animation: spin 0.8s linear infinite;
2040
  }
2041
 
 
2051
  border: 1px solid #ffcdd2;
2052
  font-weight: 500;
2053
  }
2054
+
2055
+ [data-theme="dark"] .error {
2056
+ color: #ff6b6b;
2057
+ background: rgba(211, 47, 47, 0.2);
2058
+ border: 1px solid rgba(211, 47, 47, 0.4);
2059
+ }
2060
 
2061
  .empty {
2062
  color: var(--text-secondary);
 
2105
  .visualization svg {
2106
  display: block;
2107
  background: var(--bg-primary);
2108
+ }
2109
+
2110
+ /* ============================================
2111
+ RESPONSIVE DESIGN
2112
+ ============================================ */
2113
+ @media (max-width: 1024px) {
2114
+ .nav-sidebar {
2115
+ width: 200px;
2116
+ }
2117
+
2118
+ .nav-sidebar.collapsed {
2119
+ width: 50px;
2120
+ }
2121
+
2122
+ .app-main {
2123
+ margin-left: 200px;
2124
+ width: calc(100% - 200px);
2125
+ }
2126
+
2127
+ .nav-sidebar.collapsed ~ .app-main {
2128
+ margin-left: 50px;
2129
+ width: calc(100% - 50px);
2130
+ }
2131
+
2132
+ .filters-bar-content {
2133
+ gap: 1rem;
2134
+ padding: 0.75rem 1rem;
2135
+ }
2136
+
2137
+ .filter-section {
2138
+ gap: 0.75rem;
2139
+ padding-right: 1rem;
2140
+ }
2141
+
2142
+ .sidebar {
2143
+ width: 300px;
2144
+ padding: 1rem;
2145
+ }
2146
+
2147
+ .header-content {
2148
+ flex-direction: column;
2149
+ align-items: flex-start;
2150
+ }
2151
+
2152
+ .header-right {
2153
+ width: 100%;
2154
+ justify-content: flex-start;
2155
+ margin-top: 0.5rem;
2156
+ }
2157
+ }
2158
+
2159
+ @media (max-width: 768px) {
2160
+ .nav-sidebar {
2161
+ width: 60px;
2162
+ }
2163
+
2164
+ .nav-sidebar .nav-sidebar-header h1,
2165
+ .nav-sidebar .nav-subtitle {
2166
+ display: none;
2167
+ }
2168
+
2169
+ .app-main {
2170
+ margin-left: 60px;
2171
+ width: calc(100% - 60px);
2172
+ }
2173
+
2174
+ .control-bar-content {
2175
+ flex-wrap: wrap;
2176
+ gap: 0.75rem;
2177
+ padding: 0.5rem 0.75rem;
2178
+ }
2179
+
2180
+ .control-bar-center {
2181
+ order: 2;
2182
+ width: 100%;
2183
+ justify-content: flex-start;
2184
+ gap: 0.75rem;
2185
+ }
2186
+
2187
+ .control-bar-right {
2188
+ order: 1;
2189
+ width: 100%;
2190
+ justify-content: flex-start;
2191
+ }
2192
+
2193
+ .control-search {
2194
+ width: 100%;
2195
+ }
2196
+
2197
+ .control-search-input {
2198
+ width: 100%;
2199
+ max-width: none;
2200
+ }
2201
+
2202
+ .filters-dropdown {
2203
+ left: auto;
2204
+ right: 0;
2205
+ min-width: 200px;
2206
+ }
2207
+
2208
+ .control-select {
2209
+ min-width: 110px;
2210
+ }
2211
+
2212
+ .App-header {
2213
+ padding: 1rem;
2214
+ }
2215
+
2216
+ .App-header h1 {
2217
+ font-size: 1.25rem;
2218
+ }
2219
+
2220
+ .nav-tab {
2221
+ padding: 0.4rem 0.75rem;
2222
+ font-size: 0.85rem;
2223
+ }
2224
+
2225
+ .sidebar {
2226
+ width: 100%;
2227
+ max-width: 100%;
2228
+ border-right: none;
2229
+ border-bottom: 1px solid var(--border-light);
2230
+ }
2231
+
2232
+ .main-content {
2233
+ flex-direction: column;
2234
+ height: auto;
2235
+ }
2236
+
2237
+ .visualization {
2238
+ min-height: 500px;
2239
+ }
2240
+
2241
+ .nav-sidebar {
2242
+ width: 200px;
2243
+ }
2244
+
2245
+ .app-main {
2246
+ margin-left: 200px;
2247
+ }
2248
+
2249
+ .nav-sidebar-header h1 {
2250
+ font-size: 1.25rem;
2251
+ }
2252
+
2253
+ .nav-tab {
2254
+ font-size: 0.85rem;
2255
+ padding: 0.6rem 1rem;
2256
+ }
2257
+ }
2258
+
2259
+ @media (max-width: 480px) {
2260
+ .nav-sidebar {
2261
+ width: 180px;
2262
+ }
2263
+
2264
+ .app-main {
2265
+ margin-left: 180px;
2266
+ width: calc(100% - 180px);
2267
+ }
2268
+
2269
+ .nav-sidebar-header {
2270
+ padding: 1.5rem 1rem 1rem;
2271
+ }
2272
+
2273
+ .nav-tabs {
2274
+ padding: 0.75rem 0;
2275
+ }
2276
+
2277
+ .nav-tab {
2278
+ font-size: 0.8rem;
2279
+ padding: 0.5rem 1rem;
2280
+ }
2281
+
2282
+ .filters-top-bar {
2283
+ padding: 0.5rem;
2284
+ }
2285
+
2286
+ .filters-bar-content {
2287
+ gap: 0.5rem;
2288
+ flex-wrap: wrap;
2289
+ }
2290
+
2291
+ .filter-input {
2292
+ min-width: 120px;
2293
+ font-size: 0.75rem;
2294
+ padding: 0.4rem 0.5rem;
2295
+ }
2296
+
2297
+ .filter-select {
2298
+ min-width: 80px;
2299
+ font-size: 0.75rem;
2300
+ padding: 0.35rem 0.4rem;
2301
+ }
2302
+
2303
+ .filter-range {
2304
+ width: 60px;
2305
+ }
2306
+
2307
+ .filter-label-inline {
2308
+ font-size: 0.75rem;
2309
+ }
2310
+
2311
+ .filter-label-inline span:first-child {
2312
+ min-width: 50px;
2313
+ }
2314
+
2315
+ .filter-value-inline {
2316
+ min-width: 40px;
2317
+ font-size: 0.75rem;
2318
+ }
2319
  }
frontend/src/App.tsx CHANGED
@@ -1,37 +1,38 @@
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';
32
 
33
  const logger = {
34
  error: (message: string, error?: unknown) => {
 
 
 
 
 
 
 
 
 
 
35
  if (process.env.NODE_ENV === 'development') {
36
  console.error(message, error);
37
  }
@@ -39,16 +40,11 @@ const logger = {
39
  };
40
 
41
  function App() {
42
- // Filter store state
43
  const {
44
  viewMode,
45
  colorBy,
46
  sizeBy,
47
  colorScheme,
48
- showLabels,
49
- zoomLevel,
50
- // nodeDensity,
51
- // renderingStyle,
52
  theme,
53
  selectedClusters,
54
  searchQuery,
@@ -58,16 +54,7 @@ function App() {
58
  setColorBy,
59
  setSizeBy,
60
  setColorScheme,
61
- setShowLabels,
62
- setZoomLevel,
63
- // setNodeDensity,
64
- // setRenderingStyle,
65
- // setSelectedClusters,
66
  setSearchQuery,
67
- setMinDownloads,
68
- setMinLikes,
69
- getActiveFilterCount,
70
- resetFilters: resetFilterStore,
71
  } = useFilterStore();
72
 
73
  // Initialize theme on mount
@@ -76,65 +63,67 @@ function App() {
76
  }, [theme]);
77
 
78
  const [data, setData] = useState<ModelPoint[]>([]);
79
- const [filteredCount, setFilteredCount] = useState<number | null>(null);
80
- const [stats, setStats] = useState<Stats | null>(null);
 
81
  const [loading, setLoading] = useState(true);
 
 
82
  const [error, setError] = useState<string | null>(null);
83
  const [selectedModel, setSelectedModel] = useState<ModelPoint | null>(null);
84
  const [isModalOpen, setIsModalOpen] = useState(false);
85
- const [selectedModels, setSelectedModels] = useState<ModelPoint[]>([]);
86
- const [baseModelsOnly, setBaseModelsOnly] = useState(false);
87
- const [semanticSimilarityMode, setSemanticSimilarityMode] = useState(false);
88
- const [semanticQueryModel, setSemanticQueryModel] = useState<string | null>(null);
 
 
 
 
 
 
89
 
90
- const [familyTree, setFamilyTree] = useState<ModelPoint[]>([]);
91
- const [familyTreeModelId, setFamilyTreeModelId] = useState<string | null>(null);
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[]>([]);
99
- const [similarModels, setSimilarModels] = useState<SimilarModel[]>([]);
100
- const [showSimilar, setShowSimilar] = useState(false);
101
- const [showLegend, setShowLegend] = useState(true);
102
- const [hoveredModel, setHoveredModel] = useState<ModelPoint | null>(null);
103
- const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
104
 
105
- // Structural visualization options
106
- const [showNetworkEdges, setShowNetworkEdges] = useState(false);
107
- const [showStructuralGroups, setShowStructuralGroups] = useState(false);
108
- const [overviewMode, setOverviewMode] = useState(false);
109
- const [networkEdgeType, setNetworkEdgeType] = useState<'library' | 'pipeline' | 'combined'>('combined');
110
- const [maxHierarchyDepth, setMaxHierarchyDepth] = useState<number | null>(null);
111
- const [showDistanceHeatmap, setShowDistanceHeatmap] = useState(false);
112
- const [highlightedPath, setHighlightedPath] = useState<string[]>([]);
113
- const [useGraphEmbeddings, setUseGraphEmbeddings] = useState(false);
114
- const [embeddingType, setEmbeddingType] = useState<string>('text-only');
115
- const [clusters, setClusters] = useState<Cluster[]>([]);
116
- const [clustersLoading, setClustersLoading] = useState(false);
117
 
118
- const activeFilterCount = getActiveFilterCount();
119
-
120
- const resetFilters = useCallback(() => {
121
- resetFilterStore();
122
- setMinDownloads(0);
123
- setMinLikes(0);
124
- setSearchQuery('');
125
- }, [resetFilterStore, setMinDownloads, setMinLikes, setSearchQuery]);
126
 
127
- const [width, setWidth] = useState(window.innerWidth * 0.7);
128
- const [height, setHeight] = useState(window.innerHeight * 0.7);
129
 
130
  useEffect(() => {
131
  const handleResize = () => {
132
- setWidth(window.innerWidth * 0.7);
133
- setHeight(window.innerHeight * 0.7);
134
  };
135
  window.addEventListener('resize', handleResize);
136
  return () => window.removeEventListener('resize', handleResize);
137
  }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  const fetchDataAbortRef = useRef<(() => void) | null>(null);
140
 
@@ -159,16 +148,16 @@ function App() {
159
  setData(cachedModels);
160
  setFilteredCount(cachedModels.length);
161
  setLoading(false);
162
- // Fetch in background to update cache if stale
163
- setTimeout(() => {
164
- fetchData();
165
- }, 100);
166
  return;
167
  }
168
  let models: ModelPoint[];
169
  let count: number | null = null;
170
 
171
  if (semanticSimilarityMode && semanticQueryModel) {
 
 
172
  const params = new URLSearchParams({
173
  query_model_id: semanticQueryModel,
174
  k: '500',
@@ -191,6 +180,8 @@ function App() {
191
  count = models.length;
192
  }
193
  } else {
 
 
194
  const params = new URLSearchParams({
195
  min_downloads: minDownloads.toString(),
196
  min_likes: minLikes.toString(),
@@ -204,7 +195,8 @@ function App() {
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
 
@@ -236,6 +228,7 @@ function App() {
236
  } else {
237
  models = result.models || [];
238
  count = result.filtered_count ?? models.length;
 
239
  setEmbeddingType(result.embedding_type || 'text-only');
240
  }
241
  }
@@ -253,6 +246,7 @@ function App() {
253
  } else {
254
  models = result.models || [];
255
  count = result.filtered_count ?? models.length;
 
256
  setEmbeddingType(result.embedding_type || 'text-only');
257
  }
258
  }
@@ -276,56 +270,124 @@ function App() {
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),
283
  [fetchData]
284
  );
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  // Consolidated effect to handle both search and filter changes
 
 
 
287
  useEffect(() => {
288
- // For search queries, use debounced version
289
- if (searchQuery) {
290
- debouncedFetchData();
291
- return () => {
292
- debouncedFetchData.cancel();
293
- };
294
- } else {
295
- // For filter changes without search, also use debounced version
296
- debouncedFetchData();
297
- return () => {
298
- debouncedFetchData.cancel();
299
- };
300
  }
301
- }, [searchQuery, minDownloads, minLikes, colorBy, sizeBy, baseModelsOnly, projectionMethod, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode, debouncedFetchData]);
 
 
 
 
 
 
302
 
303
- // Function to clear cache and refresh stats
304
- const clearCacheAndRefresh = useCallback(async () => {
305
- try {
306
- // Clear all caches
307
- await cache.clear('stats');
308
- await cache.clear('models');
309
- console.log('Cache cleared successfully');
310
-
311
- // Immediately fetch fresh stats
312
- const response = await fetch(`${API_BASE}/api/stats`);
313
- if (!response.ok) throw new Error('Failed to fetch stats');
314
- const statsData = await response.json();
315
- await cache.cacheStats('stats', statsData);
316
- setStats(statsData);
317
-
318
- // Refresh model data
319
- fetchData();
320
- } catch (err) {
321
- if (err instanceof Error) {
322
- logger.error('Error clearing cache:', err);
323
  }
324
- }
325
- }, [fetchData]);
 
 
 
 
 
326
 
327
  useEffect(() => {
328
- const fetchStats = async () => {
329
  const cacheKey = 'stats';
330
  const cachedStats = await cache.getCachedStats(cacheKey);
331
 
@@ -336,41 +398,71 @@ function App() {
336
  setStats(cachedStats);
337
  }
338
 
339
- // Always fetch fresh stats to update
340
- try {
341
- const response = await fetch(`${API_BASE}/api/stats`);
342
- if (!response.ok) throw new Error('Failed to fetch stats');
343
- const statsData = await response.json();
344
- await cache.cacheStats(cacheKey, statsData);
345
- setStats(statsData);
346
- } catch (err) {
347
- if (err instanceof Error) {
348
- logger.error('Error fetching stats:', err);
 
 
 
 
 
 
 
 
 
349
  }
350
  }
351
  };
352
 
353
- fetchStats();
 
 
 
 
 
354
  }, []);
355
 
356
- // Fetch clusters
357
  useEffect(() => {
358
- const fetchClusters = async () => {
359
  setClustersLoading(true);
360
- try {
361
- const response = await fetch(`${API_BASE}/api/clusters`);
362
- if (!response.ok) throw new Error('Failed to fetch clusters');
363
- const data = await response.json();
364
- setClusters(data.clusters || []);
365
- } catch (err) {
366
- logger.error('Error fetching clusters:', err);
367
- setClusters([]);
368
- } finally {
369
- setClustersLoading(false);
 
 
 
 
 
 
 
 
 
 
 
370
  }
371
  };
372
 
373
- fetchClusters();
 
 
 
 
 
374
  }, []);
375
 
376
  // Search models for family tree lookup
@@ -400,77 +492,6 @@ function App() {
400
  return () => clearTimeout(timer);
401
  }, [searchInput, searchModels]);
402
 
403
- const loadFamilyTree = useCallback(async (modelId: string) => {
404
- try {
405
- const response = await fetch(`${API_BASE}/api/family/${encodeURIComponent(modelId)}?max_depth=5`);
406
- if (!response.ok) throw new Error('Failed to load family tree');
407
- const data: FamilyTree = await response.json();
408
- setFamilyTree(data.family || []);
409
- setFamilyTreeModelId(modelId);
410
- setShowSearchResults(false);
411
- setSearchInput('');
412
- } catch (err) {
413
- logger.error('Family tree error:', err);
414
- setFamilyTree([]);
415
- setFamilyTreeModelId(null);
416
- }
417
- }, []);
418
-
419
- const clearFamilyTree = useCallback(() => {
420
- setFamilyTree([]);
421
- setFamilyTreeModelId(null);
422
- }, []);
423
-
424
- const loadFamilyPath = useCallback(async (modelId: string, targetId?: string) => {
425
- try {
426
- const url = targetId
427
- ? `${API_BASE}/api/family/path/${encodeURIComponent(modelId)}?target_id=${encodeURIComponent(targetId)}`
428
- : `${API_BASE}/api/family/path/${encodeURIComponent(modelId)}`;
429
- const response = await fetch(url);
430
- if (!response.ok) throw new Error('Failed to load path');
431
- const data = await response.json();
432
- setHighlightedPath(data.path || []);
433
- } catch (err) {
434
- logger.error('Path loading error:', err);
435
- setHighlightedPath([]);
436
- }
437
- }, []);
438
-
439
- const loadSimilarModels = useCallback(async (modelId: string) => {
440
- try {
441
- const response = await fetch(`${API_BASE}/api/similar/${encodeURIComponent(modelId)}?k=10`);
442
- if (!response.ok) {
443
- const errorText = await response.text();
444
- let errorMessage = 'Failed to load similar models';
445
- if (response.status === 404) {
446
- errorMessage = 'Model not found';
447
- } else if (response.status === 503) {
448
- errorMessage = 'Data not loaded yet. Please wait a moment and try again.';
449
- } else {
450
- try {
451
- const errorData = JSON.parse(errorText);
452
- errorMessage = errorData.detail || errorMessage;
453
- } catch {
454
- errorMessage = `Error ${response.status}: ${errorText || errorMessage}`;
455
- }
456
- }
457
- throw new Error(errorMessage);
458
- }
459
- const data = await response.json();
460
- setSimilarModels(data.similar_models || []);
461
- setShowSimilar(true);
462
- } catch (err) {
463
- const errorMessage = err instanceof Error ? err.message : 'Failed to load similar models';
464
- logger.error('Similar models error:', err);
465
- if (errorMessage !== 'Failed to load similar models' || !(err instanceof TypeError && err.message.includes('fetch'))) {
466
- setError(`Similar models: ${errorMessage}`);
467
- setTimeout(() => setError(null), 5000);
468
- }
469
- setSimilarModels([]);
470
- setShowSimilar(false);
471
- }
472
- }, []);
473
-
474
  // Bookmark management
475
  const toggleBookmark = useCallback((modelId: string) => {
476
  setBookmarkedModels(prev =>
@@ -480,851 +501,194 @@ function App() {
480
  );
481
  }, []);
482
 
483
- // Comparison management
484
- const addToComparison = useCallback((model: ModelPoint) => {
485
- if (comparisonModels.length < 3 && !comparisonModels.find(m => m.model_id === model.model_id)) {
486
- setComparisonModels(prev => [...prev, model]);
487
- }
488
- }, [comparisonModels]);
489
-
490
- const removeFromComparison = useCallback((modelId: string) => {
491
- setComparisonModels(prev => prev.filter(m => m.model_id !== modelId));
492
- }, []);
493
-
494
- // Export selected models
495
- const exportModels = useCallback(async (modelIds: string[]) => {
496
- try {
497
- const response = await fetch(`${API_BASE}/api/export`, {
498
- method: 'POST',
499
- headers: { 'Content-Type': 'application/json' },
500
- body: JSON.stringify(modelIds),
501
- });
502
- if (!response.ok) throw new Error('Export failed');
503
- const data = await response.json();
504
-
505
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
506
- const url = URL.createObjectURL(blob);
507
- const a = document.createElement('a');
508
- a.href = url;
509
- a.download = `models_export_${new Date().toISOString().split('T')[0]}.json`;
510
- document.body.appendChild(a);
511
- a.click();
512
- document.body.removeChild(a);
513
- URL.revokeObjectURL(url);
514
- } catch (err) {
515
- logger.error('Export error:', err);
516
- alert('Failed to export models');
517
- }
518
- }, []);
519
-
520
  return (
521
  <ErrorBoundary>
522
  <div className="App">
523
- <header className="App-header">
524
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem', width: '100%' }}>
525
- <div style={{ flex: '1 1 auto', minWidth: '250px' }}>
526
- <h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: '600', lineHeight: '1.2' }}>ML Ecosystem: 2M Models on Hugging Face</h1>
527
- <div style={{ marginTop: '0.5rem', fontSize: '0.85rem', opacity: 0.9, display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
528
- <a href="https://arxiv.org/abs/2508.06811" target="_blank" rel="noopener noreferrer" style={{ color: '#64b5f6', textDecoration: 'none', whiteSpace: 'nowrap' }}>Paper</a>
529
- <a href="https://github.com/bendlaufer/ai-ecosystem" target="_blank" rel="noopener noreferrer" style={{ color: '#64b5f6', textDecoration: 'none', whiteSpace: 'nowrap' }}>GitHub</a>
530
- <a href="https://huggingface.co/modelbiome" target="_blank" rel="noopener noreferrer" style={{ color: '#64b5f6', textDecoration: 'none', whiteSpace: 'nowrap' }}>Dataset</a>
531
- <span style={{ opacity: 0.7, whiteSpace: 'nowrap' }}>Laufer, Oderinwale, Kleinberg</span>
532
- </div>
533
- </div>
534
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap', flexShrink: 0 }}>
535
- <LiveModelCount compact={true} />
536
- {stats && (
537
- <>
538
- <div className="stats" style={{ display: 'flex', gap: '0.5rem', fontSize: '0.8rem', flexWrap: 'wrap' }}>
539
- <span>{stats.total_models.toLocaleString()} models</span>
540
- <span>{stats.unique_libraries} libraries</span>
541
- </div>
542
- <button
543
- onClick={clearCacheAndRefresh}
544
- style={{
545
- background: 'rgba(255, 255, 255, 0.15)',
546
- border: '1px solid rgba(255, 255, 255, 0.3)',
547
- borderRadius: '0',
548
- width: '32px',
549
- height: '32px',
550
- cursor: 'pointer',
551
- display: 'flex',
552
- alignItems: 'center',
553
- justifyContent: 'center',
554
- fontSize: '16px',
555
- transition: 'all 0.2s ease',
556
- flexShrink: 0,
557
- }}
558
- onMouseOver={(e) => {
559
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
560
- }}
561
- onMouseOut={(e) => {
562
- e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
563
- }}
564
- title="Refresh data and clear cache"
565
- aria-label="Refresh data"
566
- >
567
-
568
- </button>
569
- </>
570
- )}
571
- </div>
572
- </div>
573
- </header>
574
-
575
- <div className="main-content">
576
- <aside className="sidebar">
577
- <div style={{
578
- display: 'flex',
579
- justifyContent: 'space-between',
580
- alignItems: 'center',
581
- marginBottom: '1.5rem',
582
- paddingBottom: '1rem',
583
- borderBottom: '1px solid #e0e0e0'
584
- }}>
585
- <h2 style={{
586
- margin: 0,
587
- fontSize: '1.5rem',
588
- fontWeight: '600',
589
- color: '#2d2d2d'
590
- }}>
591
- Filters & Controls
592
- </h2>
593
- {activeFilterCount > 0 && (
594
- <div style={{
595
- fontSize: '0.75rem',
596
- background: '#4a4a4a',
597
- color: 'white',
598
- padding: '0.35rem 0.7rem',
599
- borderRadius: '0',
600
- fontWeight: '600'
601
- }}>
602
- {activeFilterCount} active
603
- </div>
604
- )}
605
- </div>
606
-
607
- {/* Filter Results Count */}
608
- {!loading && data.length > 0 && (
609
- <div className="sidebar-section" style={{
610
- background: '#f5f5f5',
611
- border: '1px solid #d0d0d0',
612
- fontSize: '0.9rem',
613
- marginBottom: '1.5rem'
614
- }}>
615
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
616
- <div>
617
- <strong style={{ fontSize: '1.1rem', color: '#2d2d2d' }}>
618
- {data.length.toLocaleString()}
619
- </strong>
620
- <span style={{ marginLeft: '0.4rem', color: '#4a4a4a' }}>
621
- {data.length === 1 ? 'model' : 'models'}
622
- </span>
623
- </div>
624
- {embeddingType === 'graph-aware' && (
625
- <span style={{
626
- fontSize: '0.7rem',
627
- background: '#4a4a4a',
628
- color: 'white',
629
- padding: '0.3rem 0.6rem',
630
- borderRadius: '0',
631
- fontWeight: '600'
632
- }}>
633
- Graph
634
- </span>
635
- )}
636
- </div>
637
- {filteredCount !== null && filteredCount !== data.length && (
638
- <div style={{ fontSize: '0.8rem', color: '#666', marginTop: '0.25rem' }}>
639
- of {filteredCount.toLocaleString()} matching
640
- </div>
641
- )}
642
- {stats && filteredCount !== null && filteredCount < stats.total_models && (
643
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
644
- from {stats.total_models.toLocaleString()} total
645
- </div>
646
- )}
647
- </div>
648
- )}
649
-
650
- {/* Search Section */}
651
- <div className="sidebar-section">
652
- <h3>Search</h3>
653
- <input
654
- type="text"
655
- value={searchQuery}
656
- onChange={(e) => setSearchQuery(e.target.value)}
657
- placeholder="Search models, tags, libraries..."
658
- style={{ width: '100%' }}
659
- title="Search by model name, tags, library, or metadata"
660
- />
661
- </div>
662
-
663
- {/* Popularity Filters */}
664
- <div className="sidebar-section">
665
- <h3>Popularity Filters</h3>
666
-
667
- <label style={{ marginBottom: '1rem', display: 'block' }}>
668
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
669
- <span style={{ fontWeight: '500' }}>Minimum Downloads</span>
670
- <span style={{ fontWeight: '600', color: '#4a4a4a' }}>
671
- {minDownloads > 0 ? minDownloads.toLocaleString() : 'Any'}
672
- </span>
673
- </div>
674
- <input
675
- type="range"
676
- min="0"
677
- max={stats ? Math.min(1000000, Math.ceil(stats.avg_downloads * 10)) : 1000000}
678
- step={stats && stats.avg_downloads > 1000 ? "1000" : "100"}
679
- value={minDownloads}
680
- onChange={(e) => setMinDownloads(Number(e.target.value))}
681
- style={{ width: '100%' }}
682
- />
683
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: '#666', marginTop: '0.25rem' }}>
684
- <span>0</span>
685
- <span>{stats ? Math.ceil(stats.avg_downloads).toLocaleString() : '100K'} avg</span>
686
- <span>{stats ? Math.min(1000000, Math.ceil(stats.avg_downloads * 10)).toLocaleString() : '1M'}</span>
687
- </div>
688
- </label>
689
-
690
- <label style={{ display: 'block' }}>
691
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
692
- <span style={{ fontWeight: '500' }}>Minimum Likes</span>
693
- <span style={{ fontWeight: '600', color: '#4a4a4a' }}>
694
- {minLikes > 0 ? minLikes.toLocaleString() : 'Any'}
695
- </span>
696
- </div>
697
- <input
698
- type="range"
699
- min="0"
700
- max={stats ? Math.min(10000, Math.ceil(stats.avg_likes * 20)) : 10000}
701
- step={stats && stats.avg_likes > 10 ? "10" : "1"}
702
- value={minLikes}
703
- onChange={(e) => setMinLikes(Number(e.target.value))}
704
- style={{ width: '100%' }}
705
- />
706
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: '#666', marginTop: '0.25rem' }}>
707
- <span>0</span>
708
- <span>{stats ? Math.ceil(stats.avg_likes).toLocaleString() : '10'} avg</span>
709
- <span>{stats ? Math.min(10000, Math.ceil(stats.avg_likes * 20)).toLocaleString() : '10K'}</span>
710
- </div>
711
- </label>
712
  </div>
713
-
714
- {/* License Filter - Collapsed */}
715
- {stats && stats.licenses && typeof stats.licenses === 'object' && Object.keys(stats.licenses).length > 0 && (
716
- <details className="sidebar-section" style={{ border: '1px solid #e0e0e0', borderRadius: '0', padding: '0.75rem' }}>
717
- <summary style={{ cursor: 'pointer', fontWeight: '600', fontSize: '0.95rem', listStyle: 'none', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
718
- <span>Licenses ({Object.keys(stats.licenses).length})</span>
719
- </summary>
720
- <div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '1rem' }}>
721
- {Object.entries(stats.licenses as Record<string, number>)
722
- .sort((a, b) => b[1] - a[1])
723
- .slice(0, 20)
724
- .map(([license, count]) => (
725
- <label
726
- key={license}
727
- style={{
728
- display: 'flex',
729
- alignItems: 'center',
730
- gap: '0.4rem',
731
- marginBottom: '0.4rem',
732
- cursor: 'pointer',
733
- fontSize: '0.85rem'
734
- }}
735
- >
736
- <input
737
- type="checkbox"
738
- checked={searchQuery.toLowerCase().includes(license.toLowerCase())}
739
- onChange={(e) => {
740
- if (e.target.checked) {
741
- setSearchQuery(searchQuery ? `${searchQuery} ${license}` : license);
742
- } else {
743
- setSearchQuery(searchQuery.replace(license, '').trim() || '');
744
- }
745
- }}
746
- />
747
- <span style={{ flex: 1 }}>{license || 'Unknown'}</span>
748
- <span style={{ fontSize: '0.7rem', color: '#999' }}>({Number(count).toLocaleString()})</span>
749
- </label>
750
- ))}
751
- </div>
752
- </details>
753
- )}
754
-
755
- {/* Quick Actions - Consolidated */}
756
- <div className="sidebar-section">
757
- <h3>Quick Actions</h3>
758
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
759
- <RandomModelButton
760
- data={data}
761
- onSelect={(model: ModelPoint) => {
762
- setSelectedModel(model);
763
- setIsModalOpen(true);
764
- }}
765
- disabled={loading || data.length === 0}
766
- />
767
  <button
768
  onClick={() => {
769
- const avgDownloads = data.reduce((sum, m) => sum + (m.downloads || 0), 0) / data.length;
770
- setMinDownloads(Math.floor(avgDownloads));
771
  }}
772
- disabled={loading || data.length === 0}
773
- style={{
774
- padding: '0.75rem',
775
- background: '#4a90e2',
776
- color: 'white',
777
- border: 'none',
778
- borderRadius: '0',
779
- cursor: loading || data.length === 0 ? 'not-allowed' : 'pointer',
780
- fontWeight: '500',
781
- fontSize: '0.9rem',
782
- opacity: loading || data.length === 0 ? 0.5 : 1
783
  }}
784
- title="Filter to models with above average downloads"
 
785
  >
786
- Popular Models
787
  </button>
788
  <button
789
- onClick={resetFilters}
790
- style={{
791
- padding: '0.75rem',
792
- background: '#6c757d',
793
- color: 'white',
794
- border: 'none',
795
- borderRadius: '0',
796
- cursor: 'pointer',
797
- fontWeight: '500',
798
- fontSize: '0.9rem'
799
  }}
800
- title="Clear all filters and reset to defaults"
 
801
  >
802
- Reset All
803
  </button>
 
 
 
 
 
 
 
 
 
804
  </div>
805
- </div>
 
806
 
807
- {/* Visualization */}
808
- <div className="sidebar-section">
809
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
810
- <h3 style={{ margin: 0 }}>Visualization</h3>
811
- <ThemeToggle />
 
 
 
 
 
 
 
 
 
 
 
812
  </div>
813
-
814
- <label style={{ marginBottom: '1rem', display: 'block' }}>
815
- <span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>View Mode</span>
816
- <select
817
- value={viewMode}
818
- onChange={(e) => setViewMode(e.target.value as ViewMode)}
819
- style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
820
- title="Choose how to visualize the models"
821
- >
822
- <option value="3d">3D Scatter</option>
823
- <option value="scatter">2D Scatter</option>
824
- <option value="network">Network</option>
825
- <option value="distribution">Distribution</option>
826
- </select>
827
- </label>
828
-
829
- {/* Zoom and Label Controls for Scatter View */}
830
- {viewMode === 'scatter' && (
831
- <>
832
- <ZoomSlider
833
- value={zoomLevel}
834
- onChange={setZoomLevel}
835
- min={0.1}
836
- max={5}
837
- step={0.1}
838
- disabled={loading}
839
- />
840
- <NodeDensitySlider disabled={loading} />
841
- <div className="label-toggle">
842
- <span className="label-toggle-label">Show Labels</span>
843
- <label className="label-toggle-switch">
844
- <input
845
- type="checkbox"
846
- checked={showLabels}
847
- onChange={(e) => setShowLabels(e.target.checked)}
848
- />
849
- <span className="label-toggle-slider"></span>
850
- </label>
851
- </div>
852
- </>
853
- )}
854
-
855
- <label style={{ marginBottom: '1rem', display: 'block' }}>
856
- <span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>Color By</span>
857
- <select
858
- value={colorBy}
859
- onChange={(e) => setColorBy(e.target.value as ColorByOption)}
860
- style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
861
- title="Choose what attribute to color models by"
862
- >
863
- <option value="library_name">Library</option>
864
- <option value="pipeline_tag">Pipeline/Task</option>
865
- <option value="cluster_id">Cluster</option>
866
- <option value="family_depth">Family Depth</option>
867
- <option value="downloads">Downloads</option>
868
- <option value="likes">Likes</option>
869
- <option value="trending_score">Trending</option>
870
- <option value="licenses">License</option>
871
- </select>
872
- </label>
873
-
874
- {/* Color Scheme */}
875
- {(colorBy === 'downloads' || colorBy === 'likes' || colorBy === 'family_depth' || colorBy === 'trending_score') && (
876
- <label style={{ marginBottom: '1rem', display: 'block' }}>
877
- <span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>Color Scheme</span>
878
- <select
879
- value={colorScheme}
880
- onChange={(e) => setColorScheme(e.target.value as any)}
881
- style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
882
- >
883
- <option value="viridis">Viridis</option>
884
- <option value="plasma">Plasma</option>
885
- <option value="inferno">Inferno</option>
886
- <option value="magma">Magma</option>
887
- <option value="coolwarm">Cool-Warm</option>
888
- </select>
889
- </label>
890
- )}
891
-
892
- {/* Legend Toggle */}
893
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
894
- <input
895
- type="checkbox"
896
- checked={showLegend}
897
- onChange={(e) => setShowLegend(e.target.checked)}
898
- style={{ cursor: 'pointer' }}
899
- />
900
- <span style={{ fontWeight: '500' }}>Show Color Legend</span>
901
- </label>
902
 
903
- <label style={{ marginBottom: '1rem', display: 'block' }}>
904
- <span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>Size By</span>
905
- <select
906
- value={sizeBy}
907
- onChange={(e) => setSizeBy(e.target.value as SizeByOption)}
908
- style={{ width: '100%', padding: '0.6rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.9rem' }}
909
- title="Choose what determines point size"
910
- >
911
- <option value="downloads">Downloads</option>
912
- <option value="likes">Likes</option>
913
- <option value="none">Uniform</option>
914
- </select>
915
- </label>
916
 
917
- <details style={{ marginBottom: '1rem', marginTop: '1rem' }}>
918
- <summary style={{ cursor: 'pointer', fontWeight: '500', fontSize: '0.9rem', marginBottom: '0rem' }}>
919
- Advanced Settings
920
- </summary>
921
- <div style={{ marginTop: '0.75rem' }}>
922
  <select
923
- value={projectionMethod}
924
- onChange={(e) => setProjectionMethod(e.target.value as 'umap' | 'tsne')}
925
- style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
926
- title="UMAP preserves global structure, t-SNE emphasizes local clusters"
927
  >
928
- <option value="umap">UMAP</option>
929
- <option value="tsne">t-SNE</option>
 
 
 
930
  </select>
931
- </div>
932
- </details>
933
- </div>
934
-
935
- {/* Display Options - Simplified */}
936
- <details className="sidebar-section" open>
937
- <summary style={{ cursor: 'pointer', fontWeight: '600', fontSize: '1rem', marginBottom: '1rem', listStyle: 'none' }}>
938
- <h3 style={{ display: 'inline', margin: 0 }}>Display Options</h3>
939
- </summary>
940
-
941
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
942
- <label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} title="Show only root models without parents">
943
- <input
944
- type="checkbox"
945
- checked={baseModelsOnly}
946
- onChange={(e) => setBaseModelsOnly(e.target.checked)}
947
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
948
- />
949
- <span style={{ fontWeight: '500', fontSize: '0.9rem' }}>Base Models Only</span>
950
- </label>
951
-
952
- <label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} title="Use family tree structure in embeddings">
953
- <input
954
- type="checkbox"
955
- checked={useGraphEmbeddings}
956
- onChange={(e) => setUseGraphEmbeddings(e.target.checked)}
957
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
958
- />
959
- <span style={{ fontWeight: '500', fontSize: '0.9rem' }}>Graph-Aware Layout</span>
960
- </label>
961
-
962
- <label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} title="Sort by similarity to a specific model">
963
- <input
964
- type="checkbox"
965
- checked={semanticSimilarityMode}
966
- onChange={(e) => {
967
- setSemanticSimilarityMode(e.target.checked);
968
- if (!e.target.checked) {
969
- setSemanticQueryModel(null);
970
- }
971
- }}
972
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
973
- />
974
- <span style={{ fontWeight: '500', fontSize: '0.9rem' }}>Similarity View</span>
975
- </label>
976
- </div>
977
-
978
- {semanticSimilarityMode && (
979
- <div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f9f9f9', borderRadius: '0', border: '1px solid #e0e0e0' }}>
980
- <input
981
- type="text"
982
- value={semanticQueryModel || ''}
983
- onChange={(e) => setSemanticQueryModel(e.target.value || null)}
984
- placeholder="Enter model ID..."
985
- style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
986
- title="Enter a model ID to compare against"
987
- />
988
- {selectedModel && (
989
- <button
990
- onClick={() => setSemanticQueryModel(selectedModel.model_id)}
991
- style={{
992
- marginTop: '0.5rem',
993
- padding: '0.4rem 0.7rem',
994
- background: '#4a90e2',
995
- color: 'white',
996
- border: 'none',
997
- borderRadius: '4px',
998
- cursor: 'pointer',
999
- fontSize: '0.8rem',
1000
- width: '100%'
1001
- }}
1002
- title="Use the currently selected model"
1003
  >
1004
- Use Selected Model
1005
- </button>
 
 
 
1006
  )}
1007
  </div>
1008
- )}
1009
- </details>
1010
 
1011
- {/* Structural Visualization Options */}
1012
- {viewMode === 'network' && (
1013
- <div className="sidebar-section">
1014
- <h3>Network Structure</h3>
1015
- <div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '1rem', lineHeight: '1.4' }}>
1016
- Explore relationships and structure in the model ecosystem
1017
- </div>
1018
-
1019
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
1020
- <input
1021
- type="checkbox"
1022
- checked={overviewMode}
1023
- onChange={(e) => setOverviewMode(e.target.checked)}
1024
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
1025
- />
1026
- <div>
1027
- <span style={{ fontWeight: '500' }}>Overview Mode</span>
1028
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
1029
- Zoom out to see full ecosystem structure with all relationships visible. Camera will automatically adjust.
1030
- </div>
1031
- </div>
1032
- </label>
1033
 
1034
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
1035
- <input
1036
- type="checkbox"
1037
- checked={showNetworkEdges}
1038
- onChange={(e) => setShowNetworkEdges(e.target.checked)}
1039
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
1040
- />
1041
- <div>
1042
- <span style={{ fontWeight: '500' }}>Network Relationships</span>
1043
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
1044
- Show connections between related models (same library, pipeline, or tags). Blue = library, Pink = pipeline.
1045
- </div>
 
1046
  </div>
1047
- </label>
1048
 
1049
- {showNetworkEdges && (
1050
- <div style={{ marginLeft: '1.5rem', marginBottom: '1rem', padding: '0.75rem', background: 'white', borderRadius: '0', border: '1px solid #d0d0d0' }}>
1051
- <label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem' }}>
1052
- Connection Type
1053
- </label>
1054
- <select
1055
- value={networkEdgeType}
1056
- onChange={(e) => setNetworkEdgeType(e.target.value as 'library' | 'pipeline' | 'combined')}
1057
- style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0', fontSize: '0.85rem' }}
1058
- >
1059
- <option value="combined">Combined (library + pipeline + tags)</option>
1060
- <option value="library">Library Only</option>
1061
- <option value="pipeline">Pipeline Only</option>
1062
- </select>
1063
- </div>
1064
- )}
1065
 
1066
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
1067
- <input
1068
- type="checkbox"
1069
- checked={showStructuralGroups}
1070
- onChange={(e) => setShowStructuralGroups(e.target.checked)}
1071
- style={{ marginRight: '0.5rem', cursor: 'pointer' }}
1072
- />
1073
- <div>
1074
- <span style={{ fontWeight: '500' }}>Structural Groupings</span>
1075
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
1076
- Highlight clusters and groups with wireframe boundaries. Shows top library and pipeline clusters.
1077
- </div>
1078
  </div>
1079
- </label>
1080
- </div>
1081
- )}
1082
 
1083
- {/* Advanced Hierarchy Controls */}
1084
- <details className="sidebar-section" style={{ border: '1px solid #e0e0e0', borderRadius: '0', padding: '0.75rem' }}>
1085
- <summary style={{ cursor: 'pointer', fontWeight: '600', fontSize: '0.95rem', listStyle: 'none' }}>
1086
- Hierarchy & Structure
1087
- </summary>
1088
- <div style={{ marginTop: '1rem' }}>
1089
- <label style={{ marginBottom: '1rem', display: 'block' }}>
1090
- <span style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
1091
- Max Hierarchy Depth
1092
- </span>
1093
- <input
1094
- type="range"
1095
- min="0"
1096
- max="10"
1097
- value={maxHierarchyDepth ?? 10}
1098
- onChange={(e) => {
1099
- const val = parseInt(e.target.value);
1100
- setMaxHierarchyDepth(val === 10 ? null : val);
1101
- }}
1102
- style={{ width: '100%' }}
1103
- />
1104
- <div style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.25rem', display: 'flex', justifyContent: 'space-between' }}>
1105
- <span>All levels</span>
1106
- <span>{maxHierarchyDepth !== null ? `Depth ≤ ${maxHierarchyDepth}` : 'No limit'}</span>
1107
- </div>
1108
- </label>
1109
- <label style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
1110
- <input
1111
- type="checkbox"
1112
- checked={showDistanceHeatmap}
1113
- onChange={(e) => setShowDistanceHeatmap(e.target.checked)}
1114
- />
1115
- <span style={{ fontSize: '0.85rem' }}>Distance Heatmap</span>
1116
- </label>
1117
- {selectedModel && (
1118
- <div style={{ marginTop: '0.5rem', padding: '0.5rem', background: '#f5f5f5', borderRadius: '0', fontSize: '0.85rem' }}>
1119
- <div style={{ fontWeight: '500', marginBottom: '0.25rem' }}>Selected Model:</div>
1120
- <div style={{ color: '#666', marginBottom: '0.5rem', wordBreak: 'break-word' }}>{selectedModel.model_id}</div>
1121
- {selectedModel.family_depth !== null && (
1122
- <div style={{ color: '#666', marginBottom: '0.5rem' }}>
1123
- Hierarchy Depth: {selectedModel.family_depth}
1124
- </div>
1125
- )}
1126
- <button
1127
- onClick={() => {
1128
- if (selectedModel.parent_model) {
1129
- loadFamilyPath(selectedModel.model_id, selectedModel.parent_model);
1130
- } else {
1131
- loadFamilyPath(selectedModel.model_id);
1132
- }
1133
- }}
1134
- style={{
1135
- padding: '0.25rem 0.5rem',
1136
- fontSize: '0.8rem',
1137
- background: '#4a90e2',
1138
- color: 'white',
1139
- border: 'none',
1140
- borderRadius: '0',
1141
- cursor: 'pointer',
1142
- marginRight: '0.5rem',
1143
- marginBottom: '0.5rem'
1144
- }}
1145
- >
1146
- Show Path to Root
1147
- </button>
1148
- <button
1149
- onClick={() => setHighlightedPath([])}
1150
- style={{
1151
- padding: '0.25rem 0.5rem',
1152
- fontSize: '0.8rem',
1153
- background: '#6a6a6a',
1154
- color: 'white',
1155
- border: 'none',
1156
- borderRadius: '0',
1157
- cursor: 'pointer',
1158
- marginBottom: '0.5rem'
1159
- }}
1160
- >
1161
- Clear Path
1162
- </button>
1163
- </div>
1164
- )}
1165
  </div>
1166
- </details>
1167
 
1168
- <div className="sidebar-section">
1169
- <h3>Family Tree Explorer</h3>
1170
- <div style={{ position: 'relative' }}>
1171
- <input
1172
- type="text"
1173
- value={searchInput}
1174
- onChange={(e) => setSearchInput(e.target.value)}
1175
- onFocus={() => searchInput.length > 0 && setShowSearchResults(true)}
1176
- placeholder="Type model name..."
1177
- style={{ width: '100%', padding: '0.5rem', borderRadius: '0', border: '1px solid #d0d0d0' }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1178
  />
1179
- {showSearchResults && searchResults.length > 0 && (
1180
- <div style={{
1181
- position: 'absolute',
1182
- top: '100%',
1183
- left: 0,
1184
- right: 0,
1185
- background: 'white',
1186
- border: '1px solid #d0d0d0',
1187
- borderRadius: '2px',
1188
- marginTop: '2px',
1189
- maxHeight: '400px',
1190
- zIndex: 1000,
1191
- boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
1192
- }}>
1193
- <VirtualSearchResults
1194
- results={searchResults}
1195
- onSelect={(result) => {
1196
- loadFamilyTree(result.model_id);
1197
- }}
1198
- />
1199
- </div>
1200
- )}
1201
  </div>
1202
- {familyTreeModelId && (
1203
- <div style={{ marginTop: '0.5rem', fontSize: '0.85rem' }}>
1204
- <div style={{ marginBottom: '0.25rem' }}>
1205
- <strong>Showing family tree for:</strong> {familyTreeModelId}
1206
- </div>
1207
- <div style={{ marginBottom: '0.5rem', color: '#666' }}>
1208
- {familyTree.length} family members
1209
- </div>
1210
- <button
1211
- onClick={clearFamilyTree}
1212
- style={{
1213
- padding: '0.25rem 0.5rem',
1214
- fontSize: '0.8rem',
1215
- background: '#6a6a6a',
1216
- color: 'white',
1217
- border: 'none',
1218
- borderRadius: '2px',
1219
- cursor: 'pointer'
1220
- }}
1221
- >
1222
- Clear Family Tree
1223
- </button>
1224
- </div>
1225
- )}
1226
  </div>
 
1227
 
1228
- {/* Bookmarks */}
1229
- {bookmarkedModels.length > 0 && (
1230
- <div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '0', border: '1px solid #d0d0d0' }}>
1231
- <h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Bookmarks ({bookmarkedModels.length})</h3>
1232
- <div style={{ maxHeight: '150px', overflowY: 'auto', fontSize: '0.85rem' }}>
1233
- {bookmarkedModels.map(modelId => (
1234
- <div key={modelId} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
1235
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{modelId}</span>
1236
- <button
1237
- onClick={() => toggleBookmark(modelId)}
1238
- style={{ marginLeft: '0.5rem', padding: '0.1rem 0.3rem', fontSize: '0.7rem', background: '#6a6a6a', color: 'white', border: 'none', borderRadius: '2px', cursor: 'pointer' }}
1239
- >
1240
- Remove
1241
- </button>
1242
- </div>
1243
- ))}
1244
- </div>
1245
- <button
1246
- onClick={() => exportModels(bookmarkedModels)}
1247
- style={{ marginTop: '0.5rem', padding: '0.25rem 0.5rem', fontSize: '0.8rem', background: '#4a4a4a', color: 'white', border: 'none', borderRadius: '2px', cursor: 'pointer', width: '100%' }}
1248
- >
1249
- Export Bookmarks
1250
- </button>
1251
- </div>
1252
- )}
1253
-
1254
- {/* Comparison */}
1255
- {comparisonModels.length > 0 && (
1256
- <div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '0', border: '1px solid #d0d0d0' }}>
1257
- <h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Comparison ({comparisonModels.length}/3)</h3>
1258
- {comparisonModels.map(model => (
1259
- <div key={model.model_id} style={{ marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '0', fontSize: '0.85rem' }}>
1260
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
1261
- <strong>{model.model_id}</strong>
1262
- <button
1263
- onClick={() => removeFromComparison(model.model_id)}
1264
- style={{ padding: '0.1rem 0.3rem', fontSize: '0.7rem', background: '#6a6a6a', color: 'white', border: 'none', borderRadius: '2px', cursor: 'pointer' }}
1265
- >
1266
- Remove
1267
- </button>
1268
- </div>
1269
- <div style={{ marginTop: '0.25rem', fontSize: '0.75rem', color: '#666' }}>
1270
- {model.library_name && <span>Library: {model.library_name} | </span>}
1271
- Downloads: {model.downloads.toLocaleString()} | Likes: {model.likes.toLocaleString()}
1272
- </div>
1273
- </div>
1274
- ))}
1275
- <button
1276
- onClick={() => setComparisonModels([])}
1277
- style={{ marginTop: '0.5rem', padding: '0.25rem 0.5rem', fontSize: '0.8rem', background: '#6a6a6a', color: 'white', border: 'none', borderRadius: '2px', cursor: 'pointer', width: '100%' }}
1278
- >
1279
- Clear Comparison
1280
- </button>
1281
- </div>
1282
- )}
1283
-
1284
- {/* Similar Models */}
1285
- {showSimilar && similarModels.length > 0 && (
1286
- <div style={{ marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '0', border: '1px solid #d0d0d0' }}>
1287
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
1288
- <h3 style={{ marginTop: 0, fontSize: '0.9rem', fontWeight: '600' }}>Similar Models</h3>
1289
- <button
1290
- onClick={() => setShowSimilar(false)}
1291
- style={{ padding: '0.1rem 0.3rem', fontSize: '0.7rem', background: '#6a6a6a', color: 'white', border: 'none', borderRadius: '2px', cursor: 'pointer' }}
1292
- >
1293
- Close
1294
- </button>
1295
- </div>
1296
- <div style={{ maxHeight: '200px', overflowY: 'auto', fontSize: '0.85rem' }}>
1297
- {similarModels.map((similar, idx) => (
1298
- <div key={idx} style={{ marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '0' }}>
1299
- <div style={{ fontWeight: '500' }}>{similar.model_id}</div>
1300
- <div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
1301
- Similarity: {(similar.similarity * 100).toFixed(1)}% | Distance: {similar.distance.toFixed(3)}
1302
- </div>
1303
- <div style={{ fontSize: '0.75rem', color: '#666' }}>
1304
- {similar.library_name && <span>{similar.library_name} | </span>}
1305
- Downloads: {similar.downloads.toLocaleString()}
1306
- </div>
1307
- </div>
1308
- ))}
1309
- </div>
1310
- </div>
1311
- )}
1312
-
1313
-
1314
- {selectedModels.length > 0 && (
1315
- <div style={{ marginTop: '1rem', padding: '0.5rem', background: '#e3f2fd', borderRadius: '0' }}>
1316
- <strong>Selected: {selectedModels.length} models</strong>
1317
- <button
1318
- onClick={() => setSelectedModels([])}
1319
- style={{ marginLeft: '0.5rem', padding: '0.25rem 0.5rem', fontSize: '0.8rem' }}
1320
- >
1321
- Clear
1322
- </button>
1323
- </div>
1324
- )}
1325
- </aside>
1326
 
1327
  <main className="visualization">
 
 
 
 
 
1328
  {loading && <div className="loading">Loading models...</div>}
1329
  {error && <div className="error">Error: {error}</div>}
1330
  {!loading && !error && data.length === 0 && (
@@ -1332,41 +696,18 @@ function App() {
1332
  )}
1333
  {!loading && !error && data.length > 0 && (
1334
  <>
1335
- {viewMode === 'scatter' && (
1336
- <ScatterPlot
1337
- width={width}
1338
- height={height}
1339
- data={data}
1340
- colorBy={colorBy}
1341
- sizeBy={sizeBy}
1342
- onPointClick={(model) => {
1343
- setSelectedModel(model);
1344
- setIsModalOpen(true);
1345
- }}
1346
- onBrush={(selected) => {
1347
- setSelectedModels(selected);
1348
- }}
1349
- />
1350
- )}
1351
  {viewMode === '3d' && (
1352
- <ScatterPlot3D
1353
- data={data}
1354
- colorBy={colorBy}
1355
- sizeBy={sizeBy}
1356
- hoveredModel={hoveredModel}
1357
- onPointClick={(model) => {
1358
- setSelectedModel(model);
1359
- setIsModalOpen(true);
1360
- }}
1361
- onHover={(model, position) => {
1362
- setHoveredModel(model);
1363
- if (model && position) {
1364
- setTooltipPosition(position);
1365
- } else {
1366
- setTooltipPosition(null);
1367
- }
1368
- }}
1369
- />
1370
  )}
1371
  {viewMode === 'network' && (
1372
  <NetworkGraph
@@ -1384,19 +725,28 @@ function App() {
1384
  )}
1385
  </>
1386
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1387
  </main>
1388
-
1389
- <ModelModal
1390
- model={selectedModel}
1391
- isOpen={isModalOpen}
1392
- onClose={() => setIsModalOpen(false)}
1393
- onBookmark={selectedModel ? () => toggleBookmark(selectedModel.model_id) : undefined}
1394
- onAddToComparison={selectedModel ? () => addToComparison(selectedModel) : undefined}
1395
- onLoadSimilar={selectedModel ? () => loadSimilarModels(selectedModel.model_id) : undefined}
1396
- isBookmarked={selectedModel ? bookmarkedModels.includes(selectedModel.model_id) : false}
1397
- />
1398
  </div>
1399
- </div>
1400
  </ErrorBoundary>
1401
  );
1402
  }
 
1
  import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
+ import { ChevronLeft, ChevronRight, Palette, Maximize2, Eye } from 'lucide-react';
3
+ import IntroModal from './components/ui/IntroModal';
4
  import ScatterPlot3D from './components/visualizations/ScatterPlot3D';
5
  import NetworkGraph from './components/visualizations/NetworkGraph';
6
  import DistributionView from './components/visualizations/DistributionView';
 
 
 
 
 
 
 
7
  import type { Cluster } from './components/controls/ClusterFilter';
 
 
 
 
 
 
8
  import ErrorBoundary from './components/ui/ErrorBoundary';
9
+ import LiveModelCounter from './components/ui/LiveModelCounter';
10
+ import ModelPopup from './components/ui/ModelPopup';
11
+ import AnalyticsPage from './pages/AnalyticsPage';
12
+ import FamiliesPage from './pages/FamiliesPage';
13
  // Types & Utils
14
+ import { ModelPoint, Stats, SearchResult } from './types';
15
+ import IntegratedSearch from './components/controls/IntegratedSearch';
16
  import cache, { IndexedDBCache } from './utils/data/indexedDB';
17
  import { debounce } from './utils/debounce';
18
  import requestManager from './utils/api/requestManager';
19
  import { fetchWithMsgPack, decodeModelsMsgPack } from './utils/api/msgpackDecoder';
20
+ import { useFilterStore, ViewMode, ColorByOption, SizeByOption, FilterState } from './stores/filterStore';
21
  import { API_BASE } from './config/api';
22
  import './App.css';
23
 
24
  const logger = {
25
  error: (message: string, error?: unknown) => {
26
+ // Suppress NetworkError messages - they're expected during backend startup
27
+ if (error instanceof Error) {
28
+ const errorMsg = error.message.toLowerCase();
29
+ if (errorMsg.includes('networkerror') ||
30
+ errorMsg.includes('failed to fetch') ||
31
+ errorMsg.includes('network request failed')) {
32
+ // Silently ignore network errors during startup
33
+ return;
34
+ }
35
+ }
36
  if (process.env.NODE_ENV === 'development') {
37
  console.error(message, error);
38
  }
 
40
  };
41
 
42
  function App() {
 
43
  const {
44
  viewMode,
45
  colorBy,
46
  sizeBy,
47
  colorScheme,
 
 
 
 
48
  theme,
49
  selectedClusters,
50
  searchQuery,
 
54
  setColorBy,
55
  setSizeBy,
56
  setColorScheme,
 
 
 
 
 
57
  setSearchQuery,
 
 
 
 
58
  } = useFilterStore();
59
 
60
  // Initialize theme on mount
 
63
  }, [theme]);
64
 
65
  const [data, setData] = useState<ModelPoint[]>([]);
66
+ const [, setFilteredCount] = useState<number | null>(null);
67
+ const [, setReturnedCount] = useState<number | null>(null);
68
+ const [, setStats] = useState<Stats | null>(null);
69
  const [loading, setLoading] = useState(true);
70
+ const [, setLoadingMessage] = useState<string>('Loading models...');
71
+ const [, setLoadingProgress] = useState<number | undefined>(undefined);
72
  const [error, setError] = useState<string | null>(null);
73
  const [selectedModel, setSelectedModel] = useState<ModelPoint | null>(null);
74
  const [isModalOpen, setIsModalOpen] = useState(false);
75
+ const [showIntro, setShowIntro] = useState(() => {
76
+ // Check if user has dismissed the intro before
77
+ return localStorage.getItem('hf-intro-dismissed') !== 'true';
78
+ });
79
+ const [baseModelsOnly] = useState(false);
80
+ const [navCollapsed, setNavCollapsed] = useState(false);
81
+ const [semanticSimilarityMode] = useState(false);
82
+ const [semanticQueryModel] = useState<string | null>(null);
83
+ const [showAnalytics, setShowAnalytics] = useState(false);
84
+ const [showFamilies, setShowFamilies] = useState(false);
85
 
86
+ const [, setSearchResults] = useState<SearchResult[]>([]);
87
+ const [searchInput] = useState('');
88
+ const [, setShowSearchResults] = useState(false);
89
+ const [projectionMethod] = useState<'umap' | 'tsne'>('umap');
 
 
 
90
  const [bookmarkedModels, setBookmarkedModels] = useState<string[]>([]);
91
+ const [, setHoveredModel] = useState<ModelPoint | null>(null);
92
+ const [, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
93
+ const [, setLiveModelCount] = useState<number | null>(null);
 
 
 
94
 
95
+ const [useGraphEmbeddings] = useState(false);
96
+ const [, setEmbeddingType] = useState<string>('text-only');
97
+ const [, setClusters] = useState<Cluster[]>([]);
98
+ const [, setClustersLoading] = useState(false);
 
 
 
 
 
 
 
 
99
 
 
 
 
 
 
 
 
 
100
 
101
+ const [width, setWidth] = useState(window.innerWidth - 240); // Account for left sidebar
102
+ const [height, setHeight] = useState(window.innerHeight - 160); // Account for header and top bar
103
 
104
  useEffect(() => {
105
  const handleResize = () => {
106
+ setWidth(window.innerWidth - 240); // Account for left sidebar (240px)
107
+ setHeight(window.innerHeight - 160); // Account for header (~80px) and top bar (~80px)
108
  };
109
  window.addEventListener('resize', handleResize);
110
  return () => window.removeEventListener('resize', handleResize);
111
  }, []);
112
+
113
+ // Stable callbacks for ScatterPlot3D to prevent re-renders
114
+ const handlePointClick = useCallback((model: ModelPoint) => {
115
+ setSelectedModel(model);
116
+ setIsModalOpen(true);
117
+ }, []);
118
+
119
+ const handleHover = useCallback((model: ModelPoint | null, position?: { x: number; y: number }) => {
120
+ setHoveredModel(model);
121
+ if (model && position) {
122
+ setTooltipPosition(position);
123
+ } else {
124
+ setTooltipPosition(null);
125
+ }
126
+ }, []);
127
 
128
  const fetchDataAbortRef = useRef<(() => void) | null>(null);
129
 
 
148
  setData(cachedModels);
149
  setFilteredCount(cachedModels.length);
150
  setLoading(false);
151
+ // Don't recursively call fetchData - it causes infinite loops
152
+ // The cache is valid, use it
 
 
153
  return;
154
  }
155
  let models: ModelPoint[];
156
  let count: number | null = null;
157
 
158
  if (semanticSimilarityMode && semanticQueryModel) {
159
+ setLoadingMessage('Finding similar models...');
160
+ setLoadingProgress(50);
161
  const params = new URLSearchParams({
162
  query_model_id: semanticQueryModel,
163
  k: '500',
 
180
  count = models.length;
181
  }
182
  } else {
183
+ setLoadingMessage('Loading embeddings and coordinates...');
184
+ setLoadingProgress(40);
185
  const params = new URLSearchParams({
186
  min_downloads: minDownloads.toString(),
187
  min_likes: minLikes.toString(),
 
195
  params.append('search_query', searchQuery);
196
  }
197
 
198
+ // Request up to 150k models for scatter plots, limit network graph for performance
199
+ params.append('max_points', viewMode === 'network' ? '500' : '150000');
200
  // Add format parameter for MessagePack support
201
  params.append('format', 'msgpack');
202
 
 
228
  } else {
229
  models = result.models || [];
230
  count = result.filtered_count ?? models.length;
231
+ setReturnedCount(result.returned_count ?? models.length);
232
  setEmbeddingType(result.embedding_type || 'text-only');
233
  }
234
  }
 
246
  } else {
247
  models = result.models || [];
248
  count = result.filtered_count ?? models.length;
249
+ setReturnedCount(result.returned_count ?? models.length);
250
  setEmbeddingType(result.embedding_type || 'text-only');
251
  }
252
  }
 
270
  setLoading(false);
271
  fetchDataAbortRef.current = null;
272
  }
273
+ }, [minDownloads, minLikes, searchQuery, projectionMethod, baseModelsOnly, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode]);
274
+
275
+ // Debounce times for different control types
276
+ const SLIDER_DEBOUNCE_MS = 500; // Sliders need longer debounce
277
+ const SEARCH_DEBOUNCE_MS = 300; // Search debounce
278
+ const DROPDOWN_DEBOUNCE_MS = 200; // Dropdowns need shorter debounce
279
 
280
  const debouncedFetchData = useMemo(
281
+ () => debounce(fetchData, SEARCH_DEBOUNCE_MS),
282
  [fetchData]
283
  );
284
 
285
+ // Debounced setters for sliders (minDownloads, minLikes)
286
+ // Debounced setter for search
287
+ const debouncedSetSearchQuery = useMemo(
288
+ () => debounce((query: string) => {
289
+ setSearchQuery(query);
290
+ }, SEARCH_DEBOUNCE_MS),
291
+ [setSearchQuery]
292
+ );
293
+
294
+ // Debounced setters for dropdowns
295
+ const debouncedSetColorBy = useMemo(
296
+ () => debounce((value: ColorByOption) => {
297
+ setColorBy(value);
298
+ }, DROPDOWN_DEBOUNCE_MS),
299
+ [setColorBy]
300
+ );
301
+
302
+ const debouncedSetSizeBy = useMemo(
303
+ () => debounce((value: SizeByOption) => {
304
+ setSizeBy(value);
305
+ }, DROPDOWN_DEBOUNCE_MS),
306
+ [setSizeBy]
307
+ );
308
+
309
+ const debouncedSetViewMode = useMemo(
310
+ () => debounce((mode: ViewMode) => {
311
+ setViewMode(mode);
312
+ }, DROPDOWN_DEBOUNCE_MS),
313
+ [setViewMode]
314
+ );
315
+
316
+ const debouncedSetColorScheme = useMemo(
317
+ () => debounce((scheme: FilterState['colorScheme']) => {
318
+ setColorScheme(scheme);
319
+ }, DROPDOWN_DEBOUNCE_MS),
320
+ [setColorScheme]
321
+ );
322
+
323
+ // Local state for search to show immediate feedback
324
+ const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
325
+
326
+ // Sync local state with store state when store changes externally
327
+ useEffect(() => {
328
+ setLocalSearchQuery(searchQuery);
329
+ }, [searchQuery]);
330
+
331
+ // Cleanup debounced functions on unmount
332
+ useEffect(() => {
333
+ return () => {
334
+ debouncedSetSearchQuery.cancel();
335
+ debouncedSetColorBy.cancel();
336
+ debouncedSetSizeBy.cancel();
337
+ debouncedSetViewMode.cancel();
338
+ debouncedSetColorScheme.cancel();
339
+ };
340
+ }, [debouncedSetSearchQuery, debouncedSetColorBy, debouncedSetSizeBy, debouncedSetViewMode, debouncedSetColorScheme]);
341
+
342
+ // Initial fetch on mount (with delay to allow backend to start)
343
+ useEffect(() => {
344
+ // Delay initial fetch to allow backend to start
345
+ const timer = setTimeout(() => {
346
+ fetchData();
347
+ }, 500); // 500ms delay
348
+
349
+ return () => clearTimeout(timer);
350
+ }, [fetchData]); // Include fetchData dependency
351
+
352
  // Consolidated effect to handle both search and filter changes
353
+ // NOTE: colorBy and sizeBy are CLIENT-SIDE only - don't refetch data for these changes
354
+ // Skip if this is the initial mount (first 600ms) - let the initial fetch effect handle it
355
+ const hasMounted = useRef(false);
356
  useEffect(() => {
357
+ if (!hasMounted.current) {
358
+ hasMounted.current = true;
359
+ return;
 
 
 
 
 
 
 
 
 
360
  }
361
+
362
+ // For search queries or filter changes, use debounced version
363
+ debouncedFetchData();
364
+ return () => {
365
+ debouncedFetchData.cancel();
366
+ };
367
+ }, [searchQuery, minDownloads, minLikes, baseModelsOnly, projectionMethod, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode, debouncedFetchData]);
368
 
369
+ // Fetch live model count
370
+ useEffect(() => {
371
+ const fetchLiveCount = async () => {
372
+ try {
373
+ const response = await fetch(`${API_BASE}/api/model-count/current?use_models_page=true&use_cache=true`);
374
+ if (response.ok) {
375
+ const data = await response.json();
376
+ setLiveModelCount(data.total_models);
377
+ }
378
+ } catch (err) {
379
+ // Silently fail - live count is optional
 
 
 
 
 
 
 
 
 
380
  }
381
+ };
382
+
383
+ fetchLiveCount();
384
+ // Refresh every 5 minutes
385
+ const interval = setInterval(fetchLiveCount, 5 * 60 * 1000);
386
+ return () => clearInterval(interval);
387
+ }, []);
388
 
389
  useEffect(() => {
390
+ const fetchStats = async (retries = 3) => {
391
  const cacheKey = 'stats';
392
  const cachedStats = await cache.getCachedStats(cacheKey);
393
 
 
398
  setStats(cachedStats);
399
  }
400
 
401
+ // Always fetch fresh stats to update with retry logic
402
+ for (let i = 0; i < retries; i++) {
403
+ try {
404
+ const response = await fetch(`${API_BASE}/api/stats`);
405
+ if (!response.ok) throw new Error('Failed to fetch stats');
406
+ const statsData = await response.json();
407
+ await cache.cacheStats(cacheKey, statsData);
408
+ setStats(statsData);
409
+ return; // Success
410
+ } catch (err) {
411
+ if (i === retries - 1) {
412
+ // Only log on final retry failure (and not NetworkError)
413
+ if (err instanceof Error && !err.message.includes('NetworkError')) {
414
+ logger.error('Error fetching stats:', err);
415
+ }
416
+ } else {
417
+ // Wait before retry (exponential backoff)
418
+ await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
419
+ }
420
  }
421
  }
422
  };
423
 
424
+ // Delay initial fetch to allow backend to start
425
+ const timer = setTimeout(() => {
426
+ fetchStats();
427
+ }, 1000);
428
+
429
+ return () => clearTimeout(timer);
430
  }, []);
431
 
432
+ // Fetch clusters with retry logic
433
  useEffect(() => {
434
+ const fetchClusters = async (retries = 3) => {
435
  setClustersLoading(true);
436
+ for (let i = 0; i < retries; i++) {
437
+ try {
438
+ const response = await fetch(`${API_BASE}/api/clusters`);
439
+ if (!response.ok) throw new Error('Failed to fetch clusters');
440
+ const data = await response.json();
441
+ setClusters(data.clusters || []);
442
+ setClustersLoading(false);
443
+ return; // Success
444
+ } catch (err) {
445
+ if (i === retries - 1) {
446
+ // Only log on final retry failure
447
+ if (err instanceof Error && !err.message.includes('NetworkError')) {
448
+ logger.error('Error fetching clusters:', err);
449
+ }
450
+ setClusters([]);
451
+ setClustersLoading(false);
452
+ } else {
453
+ // Wait before retry (exponential backoff)
454
+ await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
455
+ }
456
+ }
457
  }
458
  };
459
 
460
+ // Delay initial fetch to allow backend to start
461
+ const timer = setTimeout(() => {
462
+ fetchClusters();
463
+ }, 1000);
464
+
465
+ return () => clearTimeout(timer);
466
  }, []);
467
 
468
  // Search models for family tree lookup
 
492
  return () => clearTimeout(timer);
493
  }, [searchInput, searchModels]);
494
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  // Bookmark management
496
  const toggleBookmark = useCallback((modelId: string) => {
497
  setBookmarkedModels(prev =>
 
501
  );
502
  }, []);
503
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  return (
505
  <ErrorBoundary>
506
  <div className="App">
507
+ <div className="app-layout">
508
+ {/* Left Navigation Sidebar */}
509
+ <aside className={`nav-sidebar ${navCollapsed ? 'collapsed' : ''}`}>
510
+ <div className="nav-sidebar-header">
511
+ <h1>Hugging Face Model Lineage Viewer</h1>
512
+ <button
513
+ className="nav-collapse-toggle"
514
+ onClick={() => setNavCollapsed(!navCollapsed)}
515
+ aria-label={navCollapsed ? 'Expand navigation' : 'Collapse navigation'}
516
+ title={navCollapsed ? 'Expand navigation' : 'Collapse navigation'}
517
+ >
518
+ {navCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
519
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  </div>
521
+ {!navCollapsed && (
522
+ <nav className="nav-tabs">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  <button
524
  onClick={() => {
525
+ setShowAnalytics(false);
526
+ setShowFamilies(false);
527
  }}
528
+ className={`nav-tab ${!showAnalytics && !showFamilies ? 'active' : ''}`}
529
+ title="3D scatter plot of model embeddings — explore the model space interactively"
530
+ >
531
+ Visualization
532
+ </button>
533
+ <button
534
+ onClick={() => {
535
+ setShowAnalytics(false);
536
+ setShowFamilies(true);
 
 
537
  }}
538
+ className={`nav-tab ${showFamilies ? 'active' : ''}`}
539
+ title="Browse model families and their lineage trees"
540
  >
541
+ Families
542
  </button>
543
  <button
544
+ onClick={() => {
545
+ setShowFamilies(false);
546
+ setShowAnalytics(true);
 
 
 
 
 
 
 
547
  }}
548
+ className={`nav-tab ${showAnalytics ? 'active' : ''}`}
549
+ title="Top models, trends, and statistics"
550
  >
551
+ Analytics
552
  </button>
553
+ </nav>
554
+ )}
555
+ {!navCollapsed && (
556
+ <div className="nav-sidebar-footer">
557
+ <div className="nav-links">
558
+ <a href="https://arxiv.org/abs/2508.06811" target="_blank" rel="noopener noreferrer" title="Read the research paper on arXiv">Paper</a>
559
+ <a href="https://github.com/bendlaufer/ai-ecosystem" target="_blank" rel="noopener noreferrer" title="View source code on GitHub">GitHub</a>
560
+ <a href="https://huggingface.co/modelbiome" target="_blank" rel="noopener noreferrer" title="Access the dataset on Hugging Face">Dataset</a>
561
+ </div>
562
  </div>
563
+ )}
564
+ </aside>
565
 
566
+ {/* Main Content Area */}
567
+ <div className="app-main">
568
+ <div className="app-content">
569
+ {showAnalytics ? (
570
+ <AnalyticsPage />
571
+ ) : showFamilies ? (
572
+ <FamiliesPage />
573
+ ) : (
574
+ <div className="visualization-layout">
575
+ <div className="control-bar">
576
+ <div className="control-bar-content">
577
+ {/* Left: Title (only shown when nav is collapsed) */}
578
+ <div className="control-bar-left">
579
+ {navCollapsed && (
580
+ <span className="control-bar-title">HF Model Lineage Viewer</span>
581
+ )}
582
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
 
584
+ {/* Center: Core Controls */}
585
+ <div className="control-bar-center">
 
 
 
 
 
 
 
 
 
 
 
586
 
587
+ {/* Color by */}
588
+ <div className="control-group">
589
+ <Palette size={14} className="control-icon" />
 
 
590
  <select
591
+ value={colorBy}
592
+ onChange={(e) => debouncedSetColorBy(e.target.value as ColorByOption)}
593
+ className="control-select"
594
+ title="Color points by attribute - Changes what determines each point's color"
595
  >
596
+ <option value="family_depth">Family Depth</option>
597
+ <option value="library_name">ML Library</option>
598
+ <option value="pipeline_tag">Task Type</option>
599
+ <option value="downloads">Downloads</option>
600
+ <option value="likes">Likes</option>
601
  </select>
602
+ {(colorBy === 'downloads' || colorBy === 'likes' || colorBy === 'trending_score') && (
603
+ <select
604
+ value={colorScheme}
605
+ onChange={(e) => debouncedSetColorScheme(e.target.value as any)}
606
+ className="control-select control-select-small"
607
+ title="Color gradient style"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  >
609
+ <option value="viridis">Viridis</option>
610
+ <option value="plasma">Plasma</option>
611
+ <option value="inferno">Inferno</option>
612
+ <option value="coolwarm">Cool-Warm</option>
613
+ </select>
614
  )}
615
  </div>
 
 
616
 
617
+ <span className="control-divider" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
+ {/* Size by */}
620
+ <div className="control-group">
621
+ <Maximize2 size={14} className="control-icon" />
622
+ <select
623
+ value={sizeBy}
624
+ onChange={(e) => debouncedSetSizeBy(e.target.value as SizeByOption)}
625
+ className="control-select"
626
+ title="Size points by attribute - Larger values = bigger points"
627
+ >
628
+ <option value="downloads">By Downloads</option>
629
+ <option value="likes">By Likes</option>
630
+ <option value="none">Uniform Size</option>
631
+ </select>
632
  </div>
 
633
 
634
+ <span className="control-divider" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
 
636
+ {/* Stats summary */}
637
+ <div className="control-stats" title="Number of models currently loaded and visible in the visualization">
638
+ <Eye size={14} className="control-icon" />
639
+ <span className="control-stats-text">
640
+ {data.length.toLocaleString()} models
641
+ </span>
 
 
 
 
 
 
642
  </div>
 
 
 
643
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
  </div>
 
645
 
646
+ {/* Right: Integrated Search */}
647
+ <div className="control-bar-right">
648
+ <IntegratedSearch
649
+ value={localSearchQuery}
650
+ onChange={(value) => {
651
+ setLocalSearchQuery(value);
652
+ debouncedSetSearchQuery(value);
653
+ }}
654
+ onSelect={(result) => {
655
+ const modelPoint: ModelPoint = {
656
+ model_id: result.model_id,
657
+ x: result.x || 0,
658
+ y: result.y || 0,
659
+ z: result.z || 0,
660
+ downloads: result.downloads || 0,
661
+ likes: result.likes || 0,
662
+ trending_score: null,
663
+ tags: null,
664
+ licenses: null,
665
+ cluster_id: null,
666
+ created_at: null,
667
+ library_name: result.library_name || null,
668
+ pipeline_tag: result.pipeline_tag || null,
669
+ parent_model: null,
670
+ family_depth: result.family_depth || null,
671
+ };
672
+ setSelectedModel(modelPoint);
673
+ setIsModalOpen(true);
674
+ setLocalSearchQuery('');
675
+ setSearchQuery('');
676
+ }}
677
+ onZoomTo={(x, y, z) => {
678
+ // Zoom to point - reserved for future implementation
679
+ }}
680
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  </div>
683
+ </div>
684
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
 
686
  <main className="visualization">
687
+ {/* Intro Modal */}
688
+ {showIntro && !loading && data.length > 0 && (
689
+ <IntroModal onClose={() => setShowIntro(false)} />
690
+ )}
691
+
692
  {loading && <div className="loading">Loading models...</div>}
693
  {error && <div className="error">Error: {error}</div>}
694
  {!loading && !error && data.length === 0 && (
 
696
  )}
697
  {!loading && !error && data.length > 0 && (
698
  <>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  {viewMode === '3d' && (
700
+ <>
701
+ <ScatterPlot3D
702
+ data={data}
703
+ colorBy={colorBy}
704
+ sizeBy={sizeBy}
705
+ colorScheme={colorScheme}
706
+ hoveredModel={null}
707
+ onPointClick={handlePointClick}
708
+ onHover={handleHover}
709
+ />
710
+ </>
 
 
 
 
 
 
 
711
  )}
712
  {viewMode === 'network' && (
713
  <NetworkGraph
 
725
  )}
726
  </>
727
  )}
728
+
729
+ {/* Live Model Counter - Bottom Left */}
730
+ <LiveModelCounter
731
+ pollInterval={60000}
732
+ showGrowth={true}
733
+ />
734
+
735
+ {/* Model Popup - Bottom Left */}
736
+ <ModelPopup
737
+ model={selectedModel}
738
+ isOpen={isModalOpen}
739
+ onClose={() => setIsModalOpen(false)}
740
+ onBookmark={selectedModel ? () => toggleBookmark(selectedModel.model_id) : undefined}
741
+ isBookmarked={selectedModel ? bookmarkedModels.includes(selectedModel.model_id) : false}
742
+ />
743
  </main>
744
+ </div>
745
+ )}
746
+ </div>
747
+ </div>
748
+ </div>
 
 
 
 
 
749
  </div>
 
750
  </ErrorBoundary>
751
  );
752
  }
frontend/src/components/controls/IntegratedSearch.css ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ INTEGRATED SEARCH - TRIGGER INPUT
3
+ ============================================ */
4
+
5
+ .integrated-search-container {
6
+ position: relative;
7
+ width: 100%;
8
+ min-width: 200px;
9
+ max-width: 300px;
10
+ }
11
+
12
+ .control-search {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 0.5rem;
16
+ border: 1px solid var(--border-medium);
17
+ padding: 0.35rem 0.75rem;
18
+ background: var(--bg-primary);
19
+ cursor: pointer;
20
+ transition: border-color var(--transition-base), background-color var(--transition-base);
21
+ }
22
+
23
+ .control-search:hover {
24
+ border-color: var(--accent-blue);
25
+ background: var(--bg-secondary);
26
+ }
27
+
28
+ .search-icon {
29
+ color: var(--text-tertiary);
30
+ flex-shrink: 0;
31
+ }
32
+
33
+ .control-search-input {
34
+ border: none;
35
+ background: transparent;
36
+ color: var(--text-primary);
37
+ font-size: 0.85rem;
38
+ font-family: var(--font-primary);
39
+ flex: 1;
40
+ min-width: 0;
41
+ outline: none;
42
+ cursor: pointer;
43
+ }
44
+
45
+ .control-search-input::placeholder {
46
+ color: var(--text-tertiary);
47
+ }
48
+
49
+ .search-shortcut {
50
+ display: flex;
51
+ gap: 2px;
52
+ flex-shrink: 0;
53
+ }
54
+
55
+ .search-shortcut kbd {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ padding: 2px 5px;
60
+ font-size: 10px;
61
+ font-family: var(--font-mono);
62
+ background: var(--bg-tertiary);
63
+ border: 1px solid var(--border-light);
64
+ color: var(--text-tertiary);
65
+ line-height: 1;
66
+ }
67
+
68
+ /* ============================================
69
+ SEARCH MODAL
70
+ ============================================ */
71
+
72
+ .search-modal-overlay {
73
+ position: fixed;
74
+ top: 0;
75
+ left: 0;
76
+ right: 0;
77
+ bottom: 0;
78
+ background: rgba(0, 0, 0, 0.6);
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ z-index: 10000;
83
+ padding: 2rem;
84
+ animation: fadeIn 0.15s ease-out;
85
+ }
86
+
87
+ @keyframes fadeIn {
88
+ from { opacity: 0; }
89
+ to { opacity: 1; }
90
+ }
91
+
92
+ .search-modal {
93
+ background: var(--bg-primary);
94
+ border: 1px solid var(--border-medium);
95
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
96
+ width: 90%;
97
+ max-width: 640px;
98
+ max-height: 70vh;
99
+ display: flex;
100
+ flex-direction: column;
101
+ animation: slideDown 0.15s ease-out;
102
+ margin-top: -10vh;
103
+ }
104
+
105
+ @keyframes slideDown {
106
+ from {
107
+ opacity: 0;
108
+ transform: translateY(-10px);
109
+ }
110
+ to {
111
+ opacity: 1;
112
+ transform: translateY(0);
113
+ }
114
+ }
115
+
116
+ [data-theme="dark"] .search-modal {
117
+ background: #1a1a1a;
118
+ border-color: #3a3a3a;
119
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
120
+ }
121
+
122
+ /* Modal Header */
123
+ .search-modal-header {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 0.75rem;
127
+ padding: 1rem;
128
+ border-bottom: 1px solid var(--border-light);
129
+ }
130
+
131
+ .search-modal-input-wrapper {
132
+ flex: 1;
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 0.75rem;
136
+ }
137
+
138
+ .search-modal-icon {
139
+ color: var(--text-tertiary);
140
+ flex-shrink: 0;
141
+ }
142
+
143
+ .search-modal-input {
144
+ flex: 1;
145
+ border: none;
146
+ background: transparent;
147
+ color: var(--text-primary);
148
+ font-size: 1.1rem;
149
+ font-family: var(--font-primary);
150
+ outline: none;
151
+ }
152
+
153
+ .search-modal-input::placeholder {
154
+ color: var(--text-tertiary);
155
+ }
156
+
157
+ .search-modal-clear {
158
+ background: none;
159
+ border: none;
160
+ color: var(--text-tertiary);
161
+ cursor: pointer;
162
+ padding: 4px;
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: center;
166
+ transition: color var(--transition-base);
167
+ }
168
+
169
+ .search-modal-clear:hover {
170
+ color: var(--text-primary);
171
+ }
172
+
173
+ .search-modal-close {
174
+ background: none;
175
+ border: 1px solid var(--border-light);
176
+ color: var(--text-secondary);
177
+ cursor: pointer;
178
+ padding: 6px;
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ transition: all var(--transition-base);
183
+ }
184
+
185
+ .search-modal-close:hover {
186
+ background: var(--bg-secondary);
187
+ color: var(--text-primary);
188
+ border-color: var(--border-medium);
189
+ }
190
+
191
+ /* Modal Body */
192
+ .search-modal-body {
193
+ flex: 1;
194
+ overflow-y: auto;
195
+ min-height: 200px;
196
+ max-height: calc(70vh - 140px);
197
+ }
198
+
199
+ .search-modal-loading {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ gap: 0.75rem;
204
+ padding: 3rem;
205
+ color: var(--text-secondary);
206
+ }
207
+
208
+ .search-loading-spinner {
209
+ width: 16px;
210
+ height: 16px;
211
+ border: 2px solid var(--border-light);
212
+ border-top-color: var(--accent-blue);
213
+ border-radius: 50%;
214
+ animation: spin 0.6s linear infinite;
215
+ }
216
+
217
+ @keyframes spin {
218
+ to { transform: rotate(360deg); }
219
+ }
220
+
221
+ .search-modal-empty {
222
+ display: flex;
223
+ flex-direction: column;
224
+ align-items: center;
225
+ justify-content: center;
226
+ gap: 0.5rem;
227
+ padding: 3rem;
228
+ color: var(--text-secondary);
229
+ }
230
+
231
+ .search-empty-hint {
232
+ margin: 0;
233
+ font-size: 0.8rem;
234
+ color: var(--text-tertiary);
235
+ }
236
+
237
+ .search-modal-hint {
238
+ display: flex;
239
+ flex-direction: column;
240
+ align-items: center;
241
+ justify-content: center;
242
+ gap: 1rem;
243
+ padding: 3rem;
244
+ text-align: center;
245
+ }
246
+
247
+ .search-modal-hint p {
248
+ margin: 0;
249
+ color: var(--text-secondary);
250
+ font-size: 0.95rem;
251
+ }
252
+
253
+ .search-hint-examples {
254
+ color: var(--text-tertiary);
255
+ font-size: 0.85rem;
256
+ }
257
+
258
+ .search-hint-examples code {
259
+ background: var(--bg-tertiary);
260
+ padding: 2px 6px;
261
+ font-family: var(--font-mono);
262
+ font-size: 0.8rem;
263
+ color: var(--text-primary);
264
+ }
265
+
266
+ /* Fuzzy search feature hints */
267
+ .search-hint-features {
268
+ display: flex;
269
+ flex-direction: column;
270
+ gap: 0.5rem;
271
+ margin-bottom: 0.5rem;
272
+ }
273
+
274
+ .search-hint-feature {
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 0.5rem;
278
+ font-size: 0.85rem;
279
+ color: var(--text-secondary);
280
+ }
281
+
282
+ .search-hint-feature svg {
283
+ color: var(--accent-blue);
284
+ flex-shrink: 0;
285
+ }
286
+
287
+ .search-hint-feature strong {
288
+ color: var(--text-primary);
289
+ }
290
+
291
+ /* Results Header */
292
+ .search-results-header {
293
+ padding: 0.75rem 1rem;
294
+ font-size: 0.8rem;
295
+ font-weight: 500;
296
+ color: var(--text-secondary);
297
+ border-bottom: 1px solid var(--border-light);
298
+ background: var(--bg-secondary);
299
+ text-transform: uppercase;
300
+ letter-spacing: 0.5px;
301
+ }
302
+
303
+ /* Results List */
304
+ .search-results-list {
305
+ padding: 0.5rem 0;
306
+ }
307
+
308
+ .search-result-item {
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 1rem;
312
+ padding: 0.75rem 1rem;
313
+ cursor: pointer;
314
+ transition: background-color var(--transition-base);
315
+ }
316
+
317
+ .search-result-item:hover,
318
+ .search-result-item.selected {
319
+ background: var(--bg-secondary);
320
+ }
321
+
322
+ [data-theme="dark"] .search-result-item:hover,
323
+ [data-theme="dark"] .search-result-item.selected {
324
+ background: rgba(255, 255, 255, 0.05);
325
+ }
326
+
327
+ .search-result-content {
328
+ flex: 1;
329
+ min-width: 0;
330
+ }
331
+
332
+ .search-result-main {
333
+ display: flex;
334
+ align-items: center;
335
+ gap: 0.5rem;
336
+ margin-bottom: 0.25rem;
337
+ }
338
+
339
+ .search-result-id {
340
+ font-weight: 500;
341
+ color: var(--text-primary);
342
+ font-size: 0.9rem;
343
+ overflow: hidden;
344
+ text-overflow: ellipsis;
345
+ white-space: nowrap;
346
+ }
347
+
348
+ .search-result-similarity {
349
+ font-size: 0.75rem;
350
+ color: var(--accent-blue);
351
+ font-weight: 600;
352
+ flex-shrink: 0;
353
+ padding: 2px 6px;
354
+ background: rgba(74, 144, 226, 0.1);
355
+ }
356
+
357
+ .search-result-relevance {
358
+ font-size: 0.7rem;
359
+ color: #10b981;
360
+ font-weight: 500;
361
+ flex-shrink: 0;
362
+ padding: 2px 6px;
363
+ background: rgba(16, 185, 129, 0.1);
364
+ }
365
+
366
+ /* Fuzzy match highlighting */
367
+ .search-highlight {
368
+ background: rgba(251, 191, 36, 0.3);
369
+ color: inherit;
370
+ padding: 0 1px;
371
+ font-weight: 600;
372
+ }
373
+
374
+ [data-theme="dark"] .search-highlight {
375
+ background: rgba(251, 191, 36, 0.25);
376
+ }
377
+
378
+ /* Search type badge */
379
+ .search-type-badge {
380
+ display: inline-block;
381
+ padding: 2px 6px;
382
+ font-size: 0.65rem;
383
+ font-weight: 600;
384
+ text-transform: uppercase;
385
+ letter-spacing: 0.5px;
386
+ background: rgba(139, 92, 246, 0.15);
387
+ color: #8b5cf6;
388
+ margin-left: 0.5rem;
389
+ }
390
+
391
+ .search-result-meta {
392
+ display: flex;
393
+ gap: 0.5rem;
394
+ flex-wrap: wrap;
395
+ align-items: center;
396
+ }
397
+
398
+ .search-result-tag {
399
+ font-size: 0.7rem;
400
+ padding: 2px 6px;
401
+ background: var(--bg-tertiary);
402
+ border: 1px solid var(--border-light);
403
+ color: var(--text-secondary);
404
+ }
405
+
406
+ .search-result-stat {
407
+ display: flex;
408
+ align-items: center;
409
+ gap: 3px;
410
+ font-size: 0.7rem;
411
+ color: var(--text-tertiary);
412
+ }
413
+
414
+ .search-result-arrow {
415
+ color: var(--text-tertiary);
416
+ flex-shrink: 0;
417
+ opacity: 0;
418
+ transition: opacity var(--transition-base);
419
+ }
420
+
421
+ .search-result-item:hover .search-result-arrow,
422
+ .search-result-item.selected .search-result-arrow {
423
+ opacity: 1;
424
+ }
425
+
426
+ /* Modal Footer */
427
+ .search-modal-footer {
428
+ padding: 0.75rem 1rem;
429
+ border-top: 1px solid var(--border-light);
430
+ background: var(--bg-secondary);
431
+ }
432
+
433
+ .search-modal-shortcuts {
434
+ display: flex;
435
+ gap: 1.5rem;
436
+ justify-content: center;
437
+ }
438
+
439
+ .search-modal-shortcuts span {
440
+ display: flex;
441
+ align-items: center;
442
+ gap: 4px;
443
+ font-size: 0.75rem;
444
+ color: var(--text-tertiary);
445
+ }
446
+
447
+ .search-modal-shortcuts kbd {
448
+ display: inline-flex;
449
+ align-items: center;
450
+ justify-content: center;
451
+ min-width: 20px;
452
+ padding: 2px 5px;
453
+ font-size: 10px;
454
+ font-family: var(--font-mono);
455
+ background: var(--bg-primary);
456
+ border: 1px solid var(--border-medium);
457
+ color: var(--text-secondary);
458
+ }
459
+
460
+ /* Dark theme adjustments */
461
+ [data-theme="dark"] .search-results-header {
462
+ background: rgba(255, 255, 255, 0.03);
463
+ border-bottom-color: rgba(255, 255, 255, 0.1);
464
+ }
465
+
466
+ [data-theme="dark"] .search-modal-footer {
467
+ background: rgba(255, 255, 255, 0.03);
468
+ border-top-color: rgba(255, 255, 255, 0.1);
469
+ }
470
+
471
+ [data-theme="dark"] .search-modal-header {
472
+ border-bottom-color: rgba(255, 255, 255, 0.1);
473
+ }
474
+
475
+ /* Responsive */
476
+ @media (max-width: 640px) {
477
+ .search-modal {
478
+ width: 95%;
479
+ max-height: 80vh;
480
+ }
481
+
482
+ .search-modal-shortcuts {
483
+ flex-wrap: wrap;
484
+ gap: 0.75rem;
485
+ }
486
+ }
487
+
488
+ /* Legacy dropdown styles - can be removed if not needed elsewhere */
489
+ .integrated-search-results {
490
+ display: none;
491
+ }
492
+
493
+ .search-loading {
494
+ font-size: 0.75rem;
495
+ color: var(--text-secondary);
496
+ white-space: nowrap;
497
+ margin-left: 0.5rem;
498
+ }
499
+
500
+ .search-clear {
501
+ background: none;
502
+ border: none;
503
+ color: var(--text-secondary);
504
+ font-size: 1.2rem;
505
+ cursor: pointer;
506
+ padding: 0;
507
+ width: 20px;
508
+ height: 20px;
509
+ display: flex;
510
+ align-items: center;
511
+ justify-content: center;
512
+ transition: color var(--transition-base);
513
+ flex-shrink: 0;
514
+ margin-left: 0.25rem;
515
+ }
516
+
517
+ .search-clear:hover {
518
+ color: var(--text-primary);
519
+ }
frontend/src/components/controls/IntegratedSearch.tsx ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { X, Search, ArrowRight, Download, Heart, Sparkles } from 'lucide-react';
3
+ import { API_BASE } from '../../config/api';
4
+ import './IntegratedSearch.css';
5
+
6
+ interface SearchResult {
7
+ model_id: string;
8
+ x?: number;
9
+ y?: number;
10
+ z?: number;
11
+ similarity?: number;
12
+ family_depth?: number | null;
13
+ downloads?: number;
14
+ likes?: number;
15
+ library_name?: string | null;
16
+ pipeline_tag?: string | null;
17
+ // Fuzzy search additions
18
+ score?: number;
19
+ }
20
+
21
+ interface IntegratedSearchProps {
22
+ value: string;
23
+ onChange: (value: string) => void;
24
+ onSelect?: (result: SearchResult) => void;
25
+ onZoomTo?: (x: number, y: number, z: number) => void;
26
+ }
27
+
28
+ export default function IntegratedSearch({
29
+ value,
30
+ onChange,
31
+ onSelect,
32
+ onZoomTo
33
+ }: IntegratedSearchProps) {
34
+ const [results, setResults] = useState<SearchResult[]>([]);
35
+ const [totalMatches, setTotalMatches] = useState(0);
36
+ const [selectedIndex, setSelectedIndex] = useState(-1);
37
+ const [isModalOpen, setIsModalOpen] = useState(false);
38
+ const [isLoading, setIsLoading] = useState(false);
39
+ const [searchType, setSearchType] = useState<'fuzzy' | 'semantic'>('fuzzy');
40
+ const [localQuery, setLocalQuery] = useState('');
41
+
42
+ const modalInputRef = useRef<HTMLInputElement>(null);
43
+ const triggerInputRef = useRef<HTMLInputElement>(null);
44
+ const resultsRef = useRef<HTMLDivElement>(null);
45
+
46
+ // Detect if query looks like a model ID (contains "/")
47
+ const isModelId = useCallback((query: string): boolean => {
48
+ return query.includes('/') && query.length >= 3;
49
+ }, []);
50
+
51
+ // Perform fuzzy search via API (searches all 2M+ models)
52
+ const performFuzzySearch = useCallback(async (searchQuery: string) => {
53
+ if (searchQuery.length < 2) {
54
+ setResults([]);
55
+ setTotalMatches(0);
56
+ return;
57
+ }
58
+
59
+ setIsLoading(true);
60
+
61
+ try {
62
+ const params = new URLSearchParams({
63
+ q: searchQuery,
64
+ limit: '100',
65
+ threshold: '50', // 50% minimum match score
66
+ });
67
+
68
+ const response = await fetch(`${API_BASE}/api/search/fuzzy?${params}`);
69
+
70
+ if (!response.ok) {
71
+ throw new Error('Fuzzy search failed');
72
+ }
73
+
74
+ const data = await response.json();
75
+ const models = (data.results || []).map((m: any) => ({
76
+ model_id: m.model_id,
77
+ x: m.x || 0,
78
+ y: m.y || 0,
79
+ z: m.z || 0,
80
+ downloads: m.downloads || 0,
81
+ likes: m.likes || 0,
82
+ library_name: m.library || null,
83
+ pipeline_tag: m.pipeline || null,
84
+ family_depth: m.family_depth || null,
85
+ score: m.score, // Server-side fuzzy score (0-100)
86
+ }));
87
+
88
+ setResults(models);
89
+ setTotalMatches(data.total_matches || models.length);
90
+ setSelectedIndex(-1);
91
+ } catch (error) {
92
+ console.error('Fuzzy search error:', error);
93
+ setResults([]);
94
+ setTotalMatches(0);
95
+ } finally {
96
+ setIsLoading(false);
97
+ }
98
+ }, []);
99
+
100
+ // Perform semantic search for model IDs
101
+ const performSemanticSearch = useCallback(async (queryModelId: string) => {
102
+ if (!queryModelId || queryModelId.length < 3 || !queryModelId.includes('/')) {
103
+ setResults([]);
104
+ setTotalMatches(0);
105
+ setIsLoading(false);
106
+ return;
107
+ }
108
+
109
+ setIsLoading(true);
110
+ try {
111
+ const params = new URLSearchParams({
112
+ query_model_id: queryModelId,
113
+ k: '50',
114
+ min_downloads: '0',
115
+ min_likes: '0',
116
+ projection_method: 'umap',
117
+ });
118
+
119
+ const response = await fetch(`${API_BASE}/api/models/semantic-similarity?${params}`);
120
+
121
+ if (response.status === 404) {
122
+ // Model not found, fall back to fuzzy search
123
+ setSearchType('fuzzy');
124
+ await performFuzzySearch(queryModelId);
125
+ return;
126
+ }
127
+
128
+ if (!response.ok) {
129
+ throw new Error('Semantic search failed');
130
+ }
131
+
132
+ const data = await response.json();
133
+ const models = (data.models || []).map((m: any) => ({
134
+ model_id: m.model_id,
135
+ x: m.x,
136
+ y: m.y,
137
+ z: m.z,
138
+ similarity: m.similarity,
139
+ downloads: m.downloads,
140
+ likes: m.likes,
141
+ library_name: m.library_name,
142
+ pipeline_tag: m.pipeline_tag,
143
+ family_depth: m.family_depth,
144
+ }));
145
+
146
+ // Sort by similarity (highest first)
147
+ models.sort((a: SearchResult, b: SearchResult) =>
148
+ (b.similarity || 0) - (a.similarity || 0)
149
+ );
150
+
151
+ setResults(models.slice(0, 50));
152
+ setTotalMatches(models.length);
153
+ setSelectedIndex(-1);
154
+ } catch {
155
+ // Fall back to fuzzy search on error
156
+ setSearchType('fuzzy');
157
+ await performFuzzySearch(queryModelId);
158
+ } finally {
159
+ setIsLoading(false);
160
+ }
161
+ }, [performFuzzySearch]);
162
+
163
+ // Handle search when local query changes
164
+ useEffect(() => {
165
+ if (localQuery.length < 2) {
166
+ setResults([]);
167
+ return;
168
+ }
169
+
170
+ const timer = setTimeout(() => {
171
+ // Auto-detect search type: if it looks like a model ID, use semantic search
172
+ if (isModelId(localQuery)) {
173
+ setSearchType('semantic');
174
+ performSemanticSearch(localQuery);
175
+ } else {
176
+ setSearchType('fuzzy');
177
+ performFuzzySearch(localQuery);
178
+ }
179
+ }, 150); // Faster debounce for fuzzy search
180
+
181
+ return () => clearTimeout(timer);
182
+ }, [localQuery, isModelId, performSemanticSearch, performFuzzySearch]);
183
+
184
+ const handleSelect = useCallback((result: SearchResult) => {
185
+ if (onZoomTo && result.x !== undefined && result.y !== undefined) {
186
+ onZoomTo(result.x, result.y, result.z || 0);
187
+ }
188
+
189
+ if (onSelect) {
190
+ onSelect(result);
191
+ }
192
+
193
+ setIsModalOpen(false);
194
+ setLocalQuery('');
195
+ onChange('');
196
+ }, [onSelect, onZoomTo, onChange]);
197
+
198
+ const handleKeyDown = (e: React.KeyboardEvent) => {
199
+ if (results.length === 0) return;
200
+
201
+ if (e.key === 'ArrowDown') {
202
+ e.preventDefault();
203
+ setSelectedIndex(prev =>
204
+ prev < results.length - 1 ? prev + 1 : prev
205
+ );
206
+ // Scroll selected item into view
207
+ if (resultsRef.current) {
208
+ const items = resultsRef.current.querySelectorAll('.search-result-item');
209
+ const nextIndex = selectedIndex < results.length - 1 ? selectedIndex + 1 : selectedIndex;
210
+ items[nextIndex]?.scrollIntoView({ block: 'nearest' });
211
+ }
212
+ } else if (e.key === 'ArrowUp') {
213
+ e.preventDefault();
214
+ setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
215
+ if (resultsRef.current && selectedIndex > 0) {
216
+ const items = resultsRef.current.querySelectorAll('.search-result-item');
217
+ items[selectedIndex - 1]?.scrollIntoView({ block: 'nearest' });
218
+ }
219
+ } else if (e.key === 'Enter') {
220
+ e.preventDefault();
221
+ if (selectedIndex >= 0 && results[selectedIndex]) {
222
+ handleSelect(results[selectedIndex]);
223
+ } else if (results.length > 0) {
224
+ handleSelect(results[0]);
225
+ }
226
+ } else if (e.key === 'Escape') {
227
+ setIsModalOpen(false);
228
+ }
229
+ };
230
+
231
+ const openModal = () => {
232
+ setIsModalOpen(true);
233
+ setLocalQuery(value);
234
+ setTimeout(() => modalInputRef.current?.focus(), 50);
235
+ };
236
+
237
+ const closeModal = () => {
238
+ setIsModalOpen(false);
239
+ setLocalQuery('');
240
+ setResults([]);
241
+ };
242
+
243
+ // Handle Cmd/Ctrl+K shortcut
244
+ useEffect(() => {
245
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
246
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
247
+ e.preventDefault();
248
+ openModal();
249
+ }
250
+ };
251
+
252
+ document.addEventListener('keydown', handleGlobalKeyDown);
253
+ return () => document.removeEventListener('keydown', handleGlobalKeyDown);
254
+ }, []);
255
+
256
+ // Format relevance score (server returns 0-100)
257
+ const formatScore = (score?: number) => {
258
+ if (score === undefined) return null;
259
+ return `${Math.round(score)}%`;
260
+ };
261
+
262
+ return (
263
+ <>
264
+ {/* Trigger input in control bar */}
265
+ <div className="integrated-search-container">
266
+ <div className="control-search" onClick={openModal}>
267
+ <Search size={14} className="search-icon" />
268
+ <input
269
+ ref={triggerInputRef}
270
+ type="text"
271
+ value={value}
272
+ readOnly
273
+ onClick={openModal}
274
+ onFocus={openModal}
275
+ placeholder="Search models, tags, or model ID..."
276
+ className="control-search-input"
277
+ aria-label="Search models"
278
+ title="Fuzzy search: finds models even with typos. Enter a model ID (e.g., meta-llama/Llama-2-7b) to find similar models."
279
+ />
280
+ <span className="search-shortcut">
281
+ <kbd>Cmd</kbd>
282
+ <kbd>K</kbd>
283
+ </span>
284
+ </div>
285
+ </div>
286
+
287
+ {/* Search Modal */}
288
+ {isModalOpen && (
289
+ <div className="search-modal-overlay" onClick={closeModal}>
290
+ <div className="search-modal" onClick={(e) => e.stopPropagation()}>
291
+ <div className="search-modal-header">
292
+ <div className="search-modal-input-wrapper">
293
+ <Search size={18} className="search-modal-icon" />
294
+ <input
295
+ ref={modalInputRef}
296
+ type="text"
297
+ value={localQuery}
298
+ onChange={(e) => setLocalQuery(e.target.value)}
299
+ onKeyDown={handleKeyDown}
300
+ placeholder={isModelId(localQuery) ? "Finding similar models..." : "Fuzzy search models..."}
301
+ className="search-modal-input"
302
+ autoFocus
303
+ />
304
+ {localQuery.length > 0 && (
305
+ <button
306
+ className="search-modal-clear"
307
+ onClick={() => {
308
+ setLocalQuery('');
309
+ setResults([]);
310
+ modalInputRef.current?.focus();
311
+ }}
312
+ aria-label="Clear search"
313
+ >
314
+ <X size={16} />
315
+ </button>
316
+ )}
317
+ </div>
318
+ <button className="search-modal-close" onClick={closeModal}>
319
+ <X size={20} />
320
+ </button>
321
+ </div>
322
+
323
+ <div className="search-modal-body" ref={resultsRef}>
324
+ {isLoading && (
325
+ <div className="search-modal-loading">
326
+ <div className="search-loading-spinner" />
327
+ <span>Searching...</span>
328
+ </div>
329
+ )}
330
+
331
+ {!isLoading && localQuery.length >= 2 && results.length === 0 && (
332
+ <div className="search-modal-empty">
333
+ <span>No models found for "{localQuery}"</span>
334
+ <p className="search-empty-hint">Try a different spelling or fewer characters</p>
335
+ </div>
336
+ )}
337
+
338
+ {!isLoading && localQuery.length < 2 && (
339
+ <div className="search-modal-hint">
340
+ <p>Start typing to search models</p>
341
+ <div className="search-hint-features">
342
+ <div className="search-hint-feature">
343
+ <Sparkles size={14} />
344
+ <span><strong>Fuzzy search</strong> — finds models even with typos</span>
345
+ </div>
346
+ <div className="search-hint-feature">
347
+ <Search size={14} />
348
+ <span><strong>Semantic search</strong> — enter a model ID to find similar models</span>
349
+ </div>
350
+ </div>
351
+ <div className="search-hint-examples">
352
+ <span>Try: <code>lama</code> (finds llama), <code>brt</code> (finds bert), <code>gpt2</code></span>
353
+ </div>
354
+ </div>
355
+ )}
356
+
357
+ {!isLoading && results.length > 0 && (
358
+ <>
359
+ <div className="search-results-header">
360
+ {searchType === 'semantic' ? (
361
+ <>Similar to "{localQuery}"</>
362
+ ) : (
363
+ <>{totalMatches.toLocaleString()} matches <span className="search-type-badge">fuzzy</span></>
364
+ )}
365
+ </div>
366
+ <div className="search-results-list">
367
+ {results.map((result, idx) => (
368
+ <div
369
+ key={result.model_id}
370
+ className={`search-result-item ${idx === selectedIndex ? 'selected' : ''}`}
371
+ onClick={() => handleSelect(result)}
372
+ onMouseEnter={() => setSelectedIndex(idx)}
373
+ role="option"
374
+ aria-selected={idx === selectedIndex}
375
+ >
376
+ <div className="search-result-content">
377
+ <div className="search-result-main">
378
+ <span className="search-result-id">{result.model_id}</span>
379
+ {result.similarity !== undefined && (
380
+ <span className="search-result-similarity" title="Semantic similarity">
381
+ {(result.similarity * 100).toFixed(1)}%
382
+ </span>
383
+ )}
384
+ {searchType === 'fuzzy' && result.score !== undefined && (
385
+ <span className="search-result-relevance" title="Match relevance">
386
+ {formatScore(result.score)}
387
+ </span>
388
+ )}
389
+ </div>
390
+ <div className="search-result-meta">
391
+ {result.library_name && (
392
+ <span className="search-result-tag">{result.library_name}</span>
393
+ )}
394
+ {result.pipeline_tag && (
395
+ <span className="search-result-tag">{result.pipeline_tag}</span>
396
+ )}
397
+ {result.downloads !== undefined && result.downloads > 0 && (
398
+ <span className="search-result-stat">
399
+ <Download size={10} />
400
+ {result.downloads >= 1000000
401
+ ? `${(result.downloads / 1000000).toFixed(1)}M`
402
+ : result.downloads >= 1000
403
+ ? `${(result.downloads / 1000).toFixed(0)}K`
404
+ : result.downloads}
405
+ </span>
406
+ )}
407
+ {result.likes !== undefined && result.likes > 0 && (
408
+ <span className="search-result-stat">
409
+ <Heart size={10} />
410
+ {result.likes}
411
+ </span>
412
+ )}
413
+ </div>
414
+ </div>
415
+ <ArrowRight size={14} className="search-result-arrow" />
416
+ </div>
417
+ ))}
418
+ </div>
419
+ </>
420
+ )}
421
+ </div>
422
+
423
+ <div className="search-modal-footer">
424
+ <div className="search-modal-shortcuts">
425
+ <span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
426
+ <span><kbd>Enter</kbd> Select</span>
427
+ <span><kbd>Esc</kbd> Close</span>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ </div>
432
+ )}
433
+ </>
434
+ );
435
+ }
frontend/src/components/controls/SemanticSearch.css ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .semantic-search-container {
2
+ position: relative;
3
+ width: 100%;
4
+ max-width: 600px;
5
+ }
6
+
7
+ .semantic-search-controls {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 0.75rem;
11
+ margin-bottom: 0.75rem;
12
+ padding: 0.75rem;
13
+ background: var(--bg-secondary, #f5f5f5);
14
+ border: 1px solid var(--border-light, #e0e0e0);
15
+ border-radius: 4px;
16
+ }
17
+
18
+ [data-theme="dark"] .semantic-search-controls {
19
+ background: rgba(255, 255, 255, 0.05);
20
+ border-color: rgba(255, 255, 255, 0.1);
21
+ }
22
+
23
+ .search-mode-toggle {
24
+ display: flex;
25
+ gap: 0.5rem;
26
+ }
27
+
28
+ .mode-btn {
29
+ flex: 1;
30
+ padding: 0.5rem 1rem;
31
+ background: var(--bg-primary, #ffffff);
32
+ border: 1px solid var(--border-medium, #ddd);
33
+ border-radius: 4px;
34
+ font-size: 0.875rem;
35
+ font-weight: 500;
36
+ color: var(--text-primary, #1a1a1a);
37
+ cursor: pointer;
38
+ transition: all 0.2s ease;
39
+ }
40
+
41
+ .mode-btn:hover {
42
+ background: var(--bg-secondary, #f5f5f5);
43
+ border-color: var(--accent-blue, #3b82f6);
44
+ }
45
+
46
+ .mode-btn.active {
47
+ background: var(--accent-blue, #3b82f6);
48
+ border-color: var(--accent-blue, #3b82f6);
49
+ color: #ffffff;
50
+ }
51
+
52
+ [data-theme="dark"] .mode-btn {
53
+ background: rgba(255, 255, 255, 0.05);
54
+ border-color: rgba(255, 255, 255, 0.2);
55
+ color: var(--text-primary);
56
+ }
57
+
58
+ [data-theme="dark"] .mode-btn:hover {
59
+ background: rgba(255, 255, 255, 0.1);
60
+ border-color: var(--accent-blue);
61
+ }
62
+
63
+ [data-theme="dark"] .mode-btn.active {
64
+ background: var(--accent-blue);
65
+ color: #ffffff;
66
+ }
67
+
68
+ .query-model-input {
69
+ width: 100%;
70
+ padding: 0.5rem 0.75rem;
71
+ border: 1px solid var(--border-medium, #ddd);
72
+ border-radius: 4px;
73
+ font-size: 0.875rem;
74
+ background: var(--bg-primary, #ffffff);
75
+ color: var(--text-primary, #1a1a1a);
76
+ }
77
+
78
+ [data-theme="dark"] .query-model-input {
79
+ background: rgba(255, 255, 255, 0.05);
80
+ border-color: rgba(255, 255, 255, 0.2);
81
+ color: var(--text-primary);
82
+ }
83
+
84
+ .search-filters {
85
+ display: flex;
86
+ gap: 0.5rem;
87
+ }
88
+
89
+ .depth-filter-input,
90
+ .family-filter-input {
91
+ flex: 1;
92
+ padding: 0.5rem 0.75rem;
93
+ border: 1px solid var(--border-medium, #ddd);
94
+ border-radius: 4px;
95
+ font-size: 0.875rem;
96
+ background: var(--bg-primary, #ffffff);
97
+ color: var(--text-primary, #1a1a1a);
98
+ }
99
+
100
+ [data-theme="dark"] .depth-filter-input,
101
+ [data-theme="dark"] .family-filter-input {
102
+ background: rgba(255, 255, 255, 0.05);
103
+ border-color: rgba(255, 255, 255, 0.2);
104
+ color: var(--text-primary);
105
+ }
106
+
107
+ .semantic-search-bar {
108
+ position: relative;
109
+ display: flex;
110
+ align-items: center;
111
+ }
112
+
113
+ .semantic-search-input {
114
+ width: 100%;
115
+ padding: 0.75rem 2.5rem 0.75rem 1rem;
116
+ border: 1px solid var(--border-medium, #ddd);
117
+ border-radius: 4px;
118
+ font-size: 0.95rem;
119
+ background: var(--bg-primary, #ffffff);
120
+ color: var(--text-primary, #1a1a1a);
121
+ }
122
+
123
+ [data-theme="dark"] .semantic-search-input {
124
+ background: rgba(255, 255, 255, 0.05);
125
+ border-color: rgba(255, 255, 255, 0.2);
126
+ color: var(--text-primary);
127
+ }
128
+
129
+ .semantic-search-input:focus {
130
+ outline: none;
131
+ border-color: var(--accent-blue, #3b82f6);
132
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
133
+ }
134
+
135
+ .search-loading {
136
+ position: absolute;
137
+ right: 2.5rem;
138
+ color: var(--text-secondary, #666);
139
+ animation: spin 1s linear infinite;
140
+ }
141
+
142
+ .search-clear {
143
+ position: absolute;
144
+ right: 0.75rem;
145
+ background: none;
146
+ border: none;
147
+ color: var(--text-secondary, #666);
148
+ font-size: 1.25rem;
149
+ cursor: pointer;
150
+ padding: 0.25rem;
151
+ line-height: 1;
152
+ }
153
+
154
+ .search-clear:hover {
155
+ color: var(--text-primary, #1a1a1a);
156
+ }
157
+
158
+ .semantic-search-results {
159
+ position: absolute;
160
+ top: 100%;
161
+ left: 0;
162
+ right: 0;
163
+ max-height: 400px;
164
+ overflow-y: auto;
165
+ background: var(--bg-primary, #ffffff);
166
+ border: 1px solid var(--border-medium, #ddd);
167
+ border-radius: 4px;
168
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
169
+ z-index: 1000;
170
+ margin-top: 0.25rem;
171
+ }
172
+
173
+ [data-theme="dark"] .semantic-search-results {
174
+ background: rgba(20, 20, 20, 0.98);
175
+ border-color: rgba(255, 255, 255, 0.2);
176
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
177
+ }
178
+
179
+ .semantic-search-result {
180
+ padding: 0.75rem 1rem;
181
+ border-bottom: 1px solid var(--border-light, #e8e8e8);
182
+ cursor: pointer;
183
+ transition: background 0.15s ease;
184
+ }
185
+
186
+ [data-theme="dark"] .semantic-search-result {
187
+ border-bottom-color: rgba(255, 255, 255, 0.1);
188
+ }
189
+
190
+ .semantic-search-result:hover,
191
+ .semantic-search-result.selected {
192
+ background: var(--bg-secondary, #f5f5f5);
193
+ }
194
+
195
+ [data-theme="dark"] .semantic-search-result:hover,
196
+ [data-theme="dark"] .semantic-search-result.selected {
197
+ background: rgba(255, 255, 255, 0.1);
198
+ }
199
+
200
+ .semantic-search-result:last-child {
201
+ border-bottom: none;
202
+ }
203
+
204
+ .result-header {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ gap: 0.5rem;
209
+ margin-bottom: 0.5rem;
210
+ }
211
+
212
+ .result-model-id {
213
+ font-size: 0.95rem;
214
+ font-weight: 600;
215
+ color: var(--text-primary, #1a1a1a);
216
+ word-break: break-all;
217
+ }
218
+
219
+ .similarity-badge {
220
+ padding: 0.25rem 0.5rem;
221
+ background: var(--accent-blue, #3b82f6);
222
+ color: #ffffff;
223
+ border-radius: 12px;
224
+ font-size: 0.75rem;
225
+ font-weight: 600;
226
+ white-space: nowrap;
227
+ flex-shrink: 0;
228
+ }
229
+
230
+ .result-meta {
231
+ display: flex;
232
+ flex-wrap: wrap;
233
+ align-items: center;
234
+ gap: 0.5rem;
235
+ font-size: 0.85rem;
236
+ color: var(--text-secondary, #666);
237
+ }
238
+
239
+ .result-tag {
240
+ padding: 0.25rem 0.5rem;
241
+ background: var(--bg-tertiary, #f0f0f0);
242
+ border: 1px solid var(--border-light, #e0e0e0);
243
+ border-radius: 4px;
244
+ font-size: 0.75rem;
245
+ font-weight: 500;
246
+ }
247
+
248
+ [data-theme="dark"] .result-tag {
249
+ background: rgba(255, 255, 255, 0.1);
250
+ border-color: rgba(255, 255, 255, 0.2);
251
+ }
252
+
253
+ .depth-tag {
254
+ background: var(--accent-green, #10b981);
255
+ color: #ffffff;
256
+ border-color: var(--accent-green, #10b981);
257
+ }
258
+
259
+ .result-stats {
260
+ margin-left: auto;
261
+ font-size: 0.8rem;
262
+ color: var(--text-secondary, #999);
263
+ }
264
+
265
+ .search-no-results {
266
+ padding: 2rem;
267
+ text-align: center;
268
+ color: var(--text-secondary, #666);
269
+ font-style: italic;
270
+ }
271
+
272
+ @keyframes spin {
273
+ to {
274
+ transform: rotate(360deg);
275
+ }
276
+ }
277
+
278
+
frontend/src/components/controls/SemanticSearch.tsx ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Enhanced semantic search component with depth and family filtering.
3
+ * Supports natural language queries and semantic similarity search.
4
+ */
5
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
6
+ import { X } from 'lucide-react';
7
+ import { API_BASE } from '../../config/api';
8
+ import './SemanticSearch.css';
9
+
10
+ interface SemanticSearchResult {
11
+ model_id: string;
12
+ x: number;
13
+ y: number;
14
+ z: number;
15
+ similarity?: number;
16
+ family_depth?: number | null;
17
+ parent_model?: string | null;
18
+ downloads: number;
19
+ likes: number;
20
+ library_name?: string | null;
21
+ pipeline_tag?: string | null;
22
+ }
23
+
24
+ interface SemanticSearchProps {
25
+ onSelect?: (result: SemanticSearchResult) => void;
26
+ onZoomTo?: (x: number, y: number, z: number) => void;
27
+ onSearchComplete?: (results: SemanticSearchResult[]) => void;
28
+ }
29
+
30
+ export default function SemanticSearch({
31
+ onSelect,
32
+ onZoomTo,
33
+ onSearchComplete
34
+ }: SemanticSearchProps) {
35
+ const [query, setQuery] = useState('');
36
+ const [results, setResults] = useState<SemanticSearchResult[]>([]);
37
+ const [selectedIndex, setSelectedIndex] = useState(-1);
38
+ const [isOpen, setIsOpen] = useState(false);
39
+ const [isLoading, setIsLoading] = useState(false);
40
+ const [searchMode, setSearchMode] = useState<'semantic' | 'text'>('semantic');
41
+ const [depthFilter, setDepthFilter] = useState<number | null>(null);
42
+ const [familyFilter, setFamilyFilter] = useState<string>('');
43
+ const [queryModel, setQueryModel] = useState<string>('');
44
+
45
+ const inputRef = useRef<HTMLInputElement>(null);
46
+ const resultsRef = useRef<HTMLDivElement>(null);
47
+
48
+ // Parse natural language query for depth and family hints
49
+ const parseQuery = useCallback((q: string) => {
50
+ const lower = q.toLowerCase();
51
+ let depth: number | null = null;
52
+ let family: string = '';
53
+ let cleanQuery = q;
54
+
55
+ // Extract depth hints: "depth 2", "at depth 3", "level 1", etc.
56
+ const depthMatch = lower.match(/(?:depth|level|at depth)\s*(\d+)/);
57
+ if (depthMatch) {
58
+ depth = parseInt(depthMatch[1], 10);
59
+ cleanQuery = cleanQuery.replace(new RegExp(depthMatch[0], 'gi'), '').trim();
60
+ }
61
+
62
+ // Extract family hints: "family Llama", "in Meta-Llama", etc.
63
+ const familyMatch = lower.match(/(?:family|in|from)\s+([a-zA-Z0-9\-_\/]+)/);
64
+ if (familyMatch) {
65
+ family = familyMatch[1];
66
+ cleanQuery = cleanQuery.replace(new RegExp(familyMatch[0], 'gi'), '').trim();
67
+ }
68
+
69
+ return { depth, family, cleanQuery };
70
+ }, []);
71
+
72
+ // Perform semantic search
73
+ const performSemanticSearch = useCallback(async (queryModelId: string, depth: number | null, family: string) => {
74
+ // Validate model ID format (should be org/model-name)
75
+ if (!queryModelId || queryModelId.length < 3 || !queryModelId.includes('/')) {
76
+ setResults([]);
77
+ setIsOpen(false);
78
+ setIsLoading(false);
79
+ return;
80
+ }
81
+
82
+ setIsLoading(true);
83
+ try {
84
+ const params = new URLSearchParams({
85
+ query_model_id: queryModelId,
86
+ k: '100',
87
+ min_downloads: '0',
88
+ min_likes: '0',
89
+ projection_method: 'umap',
90
+ });
91
+
92
+ const response = await fetch(`${API_BASE}/api/models/semantic-similarity?${params}`);
93
+
94
+ if (response.status === 404) {
95
+ // Model not found - this is expected for some models
96
+ setResults([]);
97
+ setIsOpen(false);
98
+ setIsLoading(false);
99
+ return;
100
+ }
101
+
102
+ if (!response.ok) {
103
+ const errorText = await response.text();
104
+ let errorMessage = 'Semantic search failed';
105
+ try {
106
+ const errorData = JSON.parse(errorText);
107
+ errorMessage = errorData.detail || errorMessage;
108
+ } catch {
109
+ errorMessage = `Error ${response.status}: ${errorText || errorMessage}`;
110
+ }
111
+ throw new Error(errorMessage);
112
+ }
113
+
114
+ const data = await response.json();
115
+ let models = data.models || [];
116
+
117
+ // Filter by depth if specified
118
+ if (depth !== null) {
119
+ models = models.filter((m: SemanticSearchResult) =>
120
+ m.family_depth !== null && m.family_depth !== undefined && m.family_depth === depth
121
+ );
122
+ }
123
+
124
+ // Filter by family if specified
125
+ if (family) {
126
+ models = models.filter((m: SemanticSearchResult) => {
127
+ const modelIdLower = m.model_id.toLowerCase();
128
+ const parentLower = (m.parent_model || '').toLowerCase();
129
+ const familyLower = family.toLowerCase();
130
+ return modelIdLower.includes(familyLower) || parentLower.includes(familyLower);
131
+ });
132
+ }
133
+
134
+ // Sort by similarity (highest first)
135
+ models.sort((a: SemanticSearchResult, b: SemanticSearchResult) =>
136
+ (b.similarity || 0) - (a.similarity || 0)
137
+ );
138
+
139
+ setResults(models.slice(0, 20)); // Top 20
140
+ setIsOpen(true);
141
+ setSelectedIndex(-1);
142
+
143
+ if (onSearchComplete) {
144
+ onSearchComplete(models);
145
+ }
146
+ } catch {
147
+ setResults([]);
148
+ } finally {
149
+ setIsLoading(false);
150
+ }
151
+ }, [onSearchComplete]);
152
+
153
+ // Perform text search with depth/family filters
154
+ const performTextSearch = useCallback(async (searchQuery: string, depth: number | null, family: string) => {
155
+ setIsLoading(true);
156
+ try {
157
+ const params = new URLSearchParams({
158
+ q: searchQuery,
159
+ limit: '50',
160
+ });
161
+
162
+ const response = await fetch(`${API_BASE}/api/search?${params}`);
163
+ if (!response.ok) throw new Error('Search failed');
164
+
165
+ const data = await response.json();
166
+ let models = data.results || [];
167
+
168
+ // Filter by depth if specified (would need to fetch family tree data)
169
+ // For now, we'll filter by family name in model_id
170
+ if (family) {
171
+ models = models.filter((m: any) => {
172
+ const modelIdLower = m.model_id.toLowerCase();
173
+ const parentLower = (m.parent_model || '').toLowerCase();
174
+ const familyLower = family.toLowerCase();
175
+ return modelIdLower.includes(familyLower) || parentLower.includes(familyLower);
176
+ });
177
+ }
178
+
179
+ // Convert to SemanticSearchResult format
180
+ const formattedResults: SemanticSearchResult[] = models.map((m: any) => ({
181
+ model_id: m.model_id,
182
+ x: m.x || 0,
183
+ y: m.y || 0,
184
+ z: m.z || 0,
185
+ downloads: m.downloads || 0,
186
+ likes: m.likes || 0,
187
+ library_name: m.library,
188
+ pipeline_tag: m.pipeline,
189
+ parent_model: m.parent_model,
190
+ }));
191
+
192
+ setResults(formattedResults.slice(0, 20));
193
+ setIsOpen(true);
194
+ setSelectedIndex(-1);
195
+
196
+ if (onSearchComplete) {
197
+ onSearchComplete(formattedResults);
198
+ }
199
+ } catch {
200
+ setResults([]);
201
+ } finally {
202
+ setIsLoading(false);
203
+ }
204
+ }, [onSearchComplete]);
205
+
206
+ // Handle search
207
+ useEffect(() => {
208
+ if (query.length < 2 && !queryModel) {
209
+ setResults([]);
210
+ setIsOpen(false);
211
+ return;
212
+ }
213
+
214
+ const timer = setTimeout(() => {
215
+ const { depth, family, cleanQuery } = parseQuery(query);
216
+
217
+ // Update filters from parsed query
218
+ if (depth !== null) setDepthFilter(depth);
219
+ if (family) setFamilyFilter(family);
220
+
221
+ if (searchMode === 'semantic' && queryModel) {
222
+ performSemanticSearch(queryModel, depth || depthFilter, family || familyFilter);
223
+ } else if (cleanQuery.length >= 2) {
224
+ performTextSearch(cleanQuery, depth || depthFilter, family || familyFilter);
225
+ }
226
+ }, 300);
227
+
228
+ return () => clearTimeout(timer);
229
+ }, [query, queryModel, searchMode, depthFilter, familyFilter, parseQuery, performSemanticSearch, performTextSearch]);
230
+
231
+ const handleSelect = useCallback((result: SemanticSearchResult) => {
232
+ if (onZoomTo && result.x !== undefined && result.y !== undefined) {
233
+ onZoomTo(result.x, result.y, result.z || 0);
234
+ }
235
+
236
+ if (onSelect) {
237
+ onSelect(result);
238
+ }
239
+
240
+ setIsOpen(false);
241
+ setQuery('');
242
+ setQueryModel('');
243
+ inputRef.current?.blur();
244
+ }, [onSelect, onZoomTo]);
245
+
246
+ const handleKeyDown = (e: React.KeyboardEvent) => {
247
+ if (!isOpen || results.length === 0) return;
248
+
249
+ if (e.key === 'ArrowDown') {
250
+ e.preventDefault();
251
+ setSelectedIndex(prev =>
252
+ prev < results.length - 1 ? prev + 1 : prev
253
+ );
254
+ } else if (e.key === 'ArrowUp') {
255
+ e.preventDefault();
256
+ setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
257
+ } else if (e.key === 'Enter') {
258
+ e.preventDefault();
259
+ if (selectedIndex >= 0 && results[selectedIndex]) {
260
+ handleSelect(results[selectedIndex]);
261
+ } else if (results.length > 0) {
262
+ handleSelect(results[0]);
263
+ }
264
+ } else if (e.key === 'Escape') {
265
+ setIsOpen(false);
266
+ inputRef.current?.blur();
267
+ }
268
+ };
269
+
270
+ return (
271
+ <div className="semantic-search-container">
272
+ <div className="semantic-search-controls">
273
+ <div className="search-mode-toggle">
274
+ <button
275
+ className={`mode-btn ${searchMode === 'semantic' ? 'active' : ''}`}
276
+ onClick={() => setSearchMode('semantic')}
277
+ title="Semantic similarity search"
278
+ >
279
+ Semantic
280
+ </button>
281
+ <button
282
+ className={`mode-btn ${searchMode === 'text' ? 'active' : ''}`}
283
+ onClick={() => setSearchMode('text')}
284
+ title="Text search"
285
+ >
286
+ Text
287
+ </button>
288
+ </div>
289
+
290
+ {searchMode === 'semantic' && (
291
+ <input
292
+ type="text"
293
+ value={queryModel}
294
+ onChange={(e) => setQueryModel(e.target.value)}
295
+ placeholder="Reference model ID (e.g., Meta-Llama-3.1-8B-Instruct)"
296
+ className="query-model-input"
297
+ />
298
+ )}
299
+
300
+ <div className="search-filters">
301
+ <input
302
+ type="number"
303
+ value={depthFilter || ''}
304
+ onChange={(e) => setDepthFilter(e.target.value ? parseInt(e.target.value, 10) : null)}
305
+ placeholder="Depth"
306
+ min="0"
307
+ className="depth-filter-input"
308
+ title="Filter by family depth (0 = root)"
309
+ />
310
+ <input
311
+ type="text"
312
+ value={familyFilter}
313
+ onChange={(e) => setFamilyFilter(e.target.value)}
314
+ placeholder="Family name"
315
+ className="family-filter-input"
316
+ title="Filter by family name"
317
+ />
318
+ </div>
319
+ </div>
320
+
321
+ <div className="semantic-search-bar">
322
+ <input
323
+ ref={inputRef}
324
+ type="text"
325
+ value={query}
326
+ onChange={(e) => setQuery(e.target.value)}
327
+ onKeyDown={handleKeyDown}
328
+ onFocus={() => results.length > 0 && setIsOpen(true)}
329
+ placeholder={
330
+ searchMode === 'semantic'
331
+ ? "e.g., 'depth 2 Llama models' or 'family Meta-Llama depth 3'"
332
+ : "Search models, orgs, tasks..."
333
+ }
334
+ className="semantic-search-input"
335
+ aria-label="Semantic search"
336
+ />
337
+ {isLoading && <div className="search-loading">Loading...</div>}
338
+ {query.length > 0 && !isLoading && (
339
+ <button
340
+ className="search-clear"
341
+ onClick={() => {
342
+ setQuery('');
343
+ setResults([]);
344
+ setIsOpen(false);
345
+ }}
346
+ aria-label="Clear search"
347
+ >
348
+ <X size={14} />
349
+ </button>
350
+ )}
351
+ </div>
352
+
353
+ {isOpen && results.length > 0 && (
354
+ <div ref={resultsRef} className="semantic-search-results" role="listbox">
355
+ {results.map((result, idx) => (
356
+ <div
357
+ key={result.model_id}
358
+ className={`semantic-search-result ${idx === selectedIndex ? 'selected' : ''}`}
359
+ onClick={() => handleSelect(result)}
360
+ role="option"
361
+ aria-selected={idx === selectedIndex}
362
+ >
363
+ <div className="result-header">
364
+ <strong className="result-model-id">{result.model_id}</strong>
365
+ {result.similarity !== undefined && (
366
+ <span className="similarity-badge">
367
+ {(result.similarity * 100).toFixed(1)}% similar
368
+ </span>
369
+ )}
370
+ </div>
371
+ <div className="result-meta">
372
+ {result.family_depth !== null && result.family_depth !== undefined && (
373
+ <span className="result-tag depth-tag">Depth {result.family_depth}</span>
374
+ )}
375
+ {result.library_name && (
376
+ <span className="result-tag">{result.library_name}</span>
377
+ )}
378
+ {result.pipeline_tag && (
379
+ <span className="result-tag">{result.pipeline_tag}</span>
380
+ )}
381
+ <span className="result-stats">
382
+ {result.downloads.toLocaleString()} downloads • {result.likes.toLocaleString()} likes
383
+ </span>
384
+ </div>
385
+ </div>
386
+ ))}
387
+ </div>
388
+ )}
389
+
390
+ {isOpen && query.length >= 2 && results.length === 0 && !isLoading && (
391
+ <div className="semantic-search-results">
392
+ <div className="search-no-results">No results found</div>
393
+ </div>
394
+ )}
395
+ </div>
396
+ );
397
+ }
398
+
frontend/src/components/controls/VisualizationModeButtons.tsx CHANGED
@@ -14,7 +14,6 @@ interface ModeOption {
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
  ];
 
14
  }
15
 
16
  const MODES: ModeOption[] = [
 
17
  { value: 'network', label: 'Network', icon: '', description: 'Network graph view' },
18
  { value: 'distribution', label: 'Distribution', icon: '', description: 'Statistical distributions' },
19
  ];
frontend/src/components/layout/SearchBar.tsx CHANGED
@@ -3,6 +3,7 @@
3
  * Integrates with filter store and triggers map zoom/modal open.
4
  */
5
  import React, { useState, useEffect, useRef, useCallback } from 'react';
 
6
  import { useFilterStore } from '../../stores/filterStore';
7
  import './SearchBar.css';
8
 
@@ -56,8 +57,7 @@ export default function SearchBar({ onSelect, onZoomTo }: SearchBarProps) {
56
  setResults(data.results || []);
57
  setIsOpen(true);
58
  setSelectedIndex(-1);
59
- } catch (err) {
60
- console.error('Search error:', err);
61
  setResults([]);
62
  } finally {
63
  setIsLoading(false);
@@ -146,7 +146,7 @@ export default function SearchBar({ onSelect, onZoomTo }: SearchBarProps) {
146
  aria-expanded={isOpen}
147
  aria-haspopup="listbox"
148
  />
149
- {isLoading && <div className="search-loading">⟳</div>}
150
  {query.length > 0 && !isLoading && (
151
  <button
152
  className="search-clear"
@@ -157,7 +157,7 @@ export default function SearchBar({ onSelect, onZoomTo }: SearchBarProps) {
157
  }}
158
  aria-label="Clear search"
159
  >
160
- ×
161
  </button>
162
  )}
163
  </div>
 
3
  * Integrates with filter store and triggers map zoom/modal open.
4
  */
5
  import React, { useState, useEffect, useRef, useCallback } from 'react';
6
+ import { X } from 'lucide-react';
7
  import { useFilterStore } from '../../stores/filterStore';
8
  import './SearchBar.css';
9
 
 
57
  setResults(data.results || []);
58
  setIsOpen(true);
59
  setSelectedIndex(-1);
60
+ } catch {
 
61
  setResults([]);
62
  } finally {
63
  setIsLoading(false);
 
146
  aria-expanded={isOpen}
147
  aria-haspopup="listbox"
148
  />
149
+ {isLoading && <div className="search-loading">Loading...</div>}
150
  {query.length > 0 && !isLoading && (
151
  <button
152
  className="search-clear"
 
157
  }}
158
  aria-label="Clear search"
159
  >
160
+ <X size={14} />
161
  </button>
162
  )}
163
  </div>
frontend/src/components/modals/FileTree.css DELETED
@@ -1,268 +0,0 @@
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;
8
- overflow-x: hidden;
9
- display: flex;
10
- flex-direction: column;
11
- }
12
-
13
- .file-tree-header {
14
- display: flex;
15
- justify-content: space-between;
16
- align-items: center;
17
- padding: 0.75rem 1rem;
18
- background: #f5f5f5;
19
- border-bottom: 1px solid #e0e0e0;
20
- font-size: 0.9rem;
21
- font-weight: 600;
22
- flex-shrink: 0;
23
- position: sticky;
24
- top: 0;
25
- z-index: 10;
26
- }
27
-
28
- .file-count-badge {
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
- }
36
-
37
- .file-tree-link {
38
- color: #4a90e2;
39
- text-decoration: none;
40
- font-size: 0.85rem;
41
- font-weight: 400;
42
- white-space: nowrap;
43
- }
44
-
45
- .file-tree-link:hover {
46
- text-decoration: underline;
47
- }
48
-
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
-
61
- .file-tree-button:hover {
62
- background: #e0e0e0;
63
- }
64
-
65
- .file-tree-button:active {
66
- background: #d0d0d0;
67
- }
68
-
69
- .file-tree-filters {
70
- padding: 0.75rem 1rem;
71
- background: #ffffff;
72
- border-bottom: 1px solid #e0e0e0;
73
- display: flex;
74
- gap: 0.75rem;
75
- flex-shrink: 0;
76
- position: sticky;
77
- top: 48px;
78
- z-index: 9;
79
- }
80
-
81
- .file-tree-search {
82
- flex: 1;
83
- position: relative;
84
- display: flex;
85
- align-items: center;
86
- }
87
-
88
- .file-tree-search-input {
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 {
98
- outline: none;
99
- border-color: #4a90e2;
100
- box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1);
101
- }
102
-
103
- .file-tree-clear {
104
- position: absolute;
105
- right: 0.5rem;
106
- background: none;
107
- border: none;
108
- cursor: pointer;
109
- color: #666;
110
- font-size: 1rem;
111
- padding: 0.25rem;
112
- display: flex;
113
- align-items: center;
114
- justify-content: center;
115
- border-radius: 0;
116
- }
117
-
118
- .file-tree-clear:hover {
119
- background: #f0f0f0;
120
- color: #1a1a1a;
121
- }
122
-
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;
132
- }
133
-
134
- .file-tree-type-filter:focus {
135
- outline: none;
136
- border-color: #4a90e2;
137
- box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1);
138
- }
139
-
140
- .file-tree {
141
- padding: 0.5rem;
142
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
143
- font-size: 0.85rem;
144
- flex: 1;
145
- overflow-y: auto;
146
- }
147
-
148
- .file-tree-node {
149
- margin: 0.125rem 0;
150
- }
151
-
152
- .file-tree-item {
153
- display: flex;
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
- }
161
-
162
- .file-tree-item.directory {
163
- cursor: pointer;
164
- }
165
-
166
- .file-tree-item.directory:hover {
167
- background: #e8f4f8;
168
- }
169
-
170
- .file-tree-item.file:hover {
171
- background: #f0f0f0;
172
- }
173
-
174
- .file-actions {
175
- display: flex;
176
- gap: 0.25rem;
177
- margin-left: auto;
178
- opacity: 0;
179
- transition: opacity 0.2s;
180
- }
181
-
182
- .file-tree-item:hover .file-actions {
183
- opacity: 1;
184
- }
185
-
186
- .file-action-btn {
187
- background: none;
188
- border: none;
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;
196
- transition: background 0.15s;
197
- text-decoration: none;
198
- color: inherit;
199
- }
200
-
201
- .file-action-btn:hover {
202
- background: rgba(0, 0, 0, 0.1);
203
- }
204
-
205
- .file-icon {
206
- font-size: 1rem;
207
- width: 1.25rem;
208
- text-align: center;
209
- }
210
-
211
- .file-name {
212
- flex: 1;
213
- color: #1a1a1a;
214
- word-break: break-all;
215
- }
216
-
217
- .file-size {
218
- color: #666;
219
- font-size: 0.8rem;
220
- margin-left: auto;
221
- }
222
-
223
- .file-expand {
224
- color: #666;
225
- font-size: 0.7rem;
226
- width: 0.75rem;
227
- text-align: center;
228
- }
229
-
230
- .file-tree-children {
231
- margin-left: 0.5rem;
232
- border-left: 1px solid #e8e8e8;
233
- padding-left: 0.5rem;
234
- margin-top: 0.125rem;
235
- }
236
-
237
- .file-tree-loading,
238
- .file-tree-error,
239
- .file-tree-empty {
240
- padding: 1rem;
241
- text-align: center;
242
- color: #666;
243
- font-size: 0.9rem;
244
- }
245
-
246
- .file-tree-error {
247
- color: #d32f2f;
248
- }
249
-
250
- /* Scrollbar styling */
251
- .file-tree-container::-webkit-scrollbar {
252
- width: 8px;
253
- }
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 {
266
- background: #a8a8a8;
267
- }
268
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/modals/FileTree.tsx DELETED
@@ -1,509 +0,0 @@
1
- /**
2
- * File tree component for displaying model file structure.
3
- * Fetches and displays files from Hugging Face model repository.
4
- */
5
- import React, { useState, useEffect, useMemo } from 'react';
6
- import { getHuggingFaceFileTreeUrl } from '../../utils/api/hfUrl';
7
- import './FileTree.css';
8
-
9
- import { API_BASE } from '../../config/api';
10
-
11
- interface FileNode {
12
- path: string;
13
- type: 'file' | 'directory';
14
- size?: number;
15
- children?: FileNode[];
16
- }
17
-
18
- interface FileTreeProps {
19
- modelId: string;
20
- }
21
-
22
- export default function FileTree({ modelId }: FileTreeProps) {
23
- const [files, setFiles] = useState<FileNode[]>([]);
24
- const [loading, setLoading] = useState(true);
25
- const [error, setError] = useState<string | null>(null);
26
- const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
27
- const [searchQuery, setSearchQuery] = useState('');
28
- const [fileTypeFilter, setFileTypeFilter] = useState<string>('all');
29
- const [showSearch, setShowSearch] = useState(false);
30
- const searchInputRef = React.useRef<HTMLInputElement>(null);
31
-
32
- useEffect(() => {
33
- if (!modelId) {
34
- setLoading(false);
35
- setError('No model ID provided');
36
- return;
37
- }
38
-
39
- const fetchFiles = async () => {
40
- setLoading(true);
41
- setError(null);
42
- try {
43
- const response = await fetch(
44
- `${API_BASE}/api/model/${encodeURIComponent(modelId)}/files?branch=main`
45
- );
46
-
47
- if (response.status === 404) {
48
- throw new Error('File tree not available for this model');
49
- }
50
-
51
- if (response.status === 503) {
52
- throw new Error('Backend service unavailable');
53
- }
54
-
55
- if (!response.ok) {
56
- const errorText = await response.text();
57
- throw new Error(`Failed to load file tree: ${response.status} ${errorText}`);
58
- }
59
-
60
- const data = await response.json();
61
-
62
- if (!Array.isArray(data)) {
63
- throw new Error('Invalid response format');
64
- }
65
-
66
- // Convert flat list to tree structure
67
- const tree = buildFileTree(data);
68
- setFiles(tree);
69
- } catch (err: any) {
70
- const errorMessage = err instanceof Error ? err.message : 'Failed to load files';
71
- setError(errorMessage);
72
- // Only log in development
73
- if (process.env.NODE_ENV === 'development') {
74
- console.error('Error fetching file tree:', err);
75
- }
76
- } finally {
77
- setLoading(false);
78
- }
79
- };
80
-
81
- fetchFiles();
82
- }, [modelId]);
83
-
84
- const buildFileTree = (fileList: any[]): FileNode[] => {
85
- if (!Array.isArray(fileList) || fileList.length === 0) {
86
- return [];
87
- }
88
-
89
- const tree: FileNode[] = [];
90
- const pathMap = new Map<string, FileNode>();
91
-
92
- // Sort files by path for consistent ordering
93
- const sortedFiles = [...fileList].sort((a, b) => {
94
- const pathA = a.path || '';
95
- const pathB = b.path || '';
96
- return pathA.localeCompare(pathB);
97
- });
98
-
99
- for (const file of sortedFiles) {
100
- if (!file.path) continue;
101
-
102
- const parts = file.path.split('/').filter((p: string) => p.length > 0);
103
- if (parts.length === 0) continue;
104
-
105
- let currentPath = '';
106
- let parent: FileNode | null = null;
107
-
108
- for (let i = 0; i < parts.length; i++) {
109
- const part = parts[i];
110
- currentPath = currentPath ? `${currentPath}/${part}` : part;
111
-
112
- if (!pathMap.has(currentPath)) {
113
- const isDirectory = i < parts.length - 1;
114
- const node: FileNode = {
115
- path: currentPath,
116
- type: isDirectory ? 'directory' : 'file',
117
- size: isDirectory ? undefined : (file.size || undefined), // Only set size for files
118
- children: isDirectory ? [] : undefined,
119
- };
120
-
121
- pathMap.set(currentPath, node);
122
-
123
- if (parent) {
124
- parent.children!.push(node);
125
- } else {
126
- tree.push(node);
127
- }
128
-
129
- parent = node;
130
- } else {
131
- parent = pathMap.get(currentPath)!;
132
- }
133
- }
134
- }
135
-
136
- return tree;
137
- };
138
-
139
- const toggleExpand = (path: string) => {
140
- setExpandedPaths((prev) => {
141
- const next = new Set(prev);
142
- if (next.has(path)) {
143
- next.delete(path);
144
- } else {
145
- next.add(path);
146
- }
147
- return next;
148
- });
149
- };
150
-
151
- const expandAll = () => {
152
- const allPaths = new Set<string>();
153
- const collectPaths = (nodes: FileNode[]) => {
154
- nodes.forEach(node => {
155
- if (node.type === 'directory' && node.children) {
156
- allPaths.add(node.path);
157
- if (node.children.length > 0) {
158
- collectPaths(node.children);
159
- }
160
- }
161
- });
162
- };
163
- collectPaths(files);
164
- setExpandedPaths(allPaths);
165
- };
166
-
167
- const collapseAll = () => {
168
- setExpandedPaths(new Set());
169
- };
170
-
171
- const formatFileSize = (bytes?: number): string => {
172
- if (!bytes) return '';
173
- if (bytes < 1024) return `${bytes} B`;
174
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
175
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
176
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
177
- };
178
-
179
- // Get all file extensions from the tree
180
- const getAllFileExtensions = useMemo(() => {
181
- const extensions = new Set<string>();
182
- const collectExtensions = (nodes: FileNode[]) => {
183
- nodes.forEach(node => {
184
- if (node.type === 'file') {
185
- const ext = node.path.split('.').pop()?.toLowerCase();
186
- if (ext) extensions.add(ext);
187
- }
188
- if (node.children) {
189
- collectExtensions(node.children);
190
- }
191
- });
192
- };
193
- collectExtensions(files);
194
- return Array.from(extensions).sort();
195
- }, [files]);
196
-
197
- // Auto-expand directories when searching
198
- useEffect(() => {
199
- if (searchQuery) {
200
- const pathsToExpand = new Set<string>();
201
- const findMatchingPaths = (nodes: FileNode[], query: string) => {
202
- nodes.forEach(node => {
203
- if (node.path.toLowerCase().includes(query.toLowerCase())) {
204
- // Expand all parent directories
205
- const parts = node.path.split('/');
206
- let currentPath = '';
207
- for (let i = 0; i < parts.length - 1; i++) {
208
- currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
209
- pathsToExpand.add(currentPath);
210
- }
211
- }
212
- if (node.children) {
213
- findMatchingPaths(node.children, query);
214
- }
215
- });
216
- };
217
- findMatchingPaths(files, searchQuery);
218
- setExpandedPaths(pathsToExpand);
219
- }
220
- }, [searchQuery, files]);
221
-
222
- // Filter files based on search and file type
223
- const filterNodes = (nodes: FileNode[]): FileNode[] => {
224
- return nodes
225
- .map(node => {
226
- const matchesSearch = !searchQuery ||
227
- node.path.toLowerCase().includes(searchQuery.toLowerCase());
228
- const matchesType = fileTypeFilter === 'all' ||
229
- (node.type === 'file' && node.path.toLowerCase().endsWith(`.${fileTypeFilter}`)) ||
230
- (node.type === 'directory');
231
-
232
- if (!matchesSearch || !matchesType) {
233
- return null;
234
- }
235
-
236
- const filteredChildren = node.children ? filterNodes(node.children) : undefined;
237
- const result: FileNode | null = filteredChildren && filteredChildren.length > 0
238
- ? { ...node, children: filteredChildren }
239
- : filteredChildren === undefined && matchesSearch && matchesType
240
- ? { ...node }
241
- : null;
242
- return result;
243
- })
244
- .filter((node): node is FileNode => node !== null);
245
- };
246
-
247
- const filteredFiles = useMemo(() => {
248
- if (!searchQuery && fileTypeFilter === 'all') return files;
249
- return filterNodes(files);
250
- }, [files, searchQuery, fileTypeFilter]);
251
-
252
- // Count total files
253
- const countFiles = (nodes: FileNode[]): number => {
254
- let count = 0;
255
- nodes.forEach(node => {
256
- if (node.type === 'file') count++;
257
- if (node.children) count += countFiles(node.children);
258
- });
259
- return count;
260
- };
261
-
262
- const totalFileCount = useMemo(() => countFiles(files), [files]);
263
- const visibleFileCount = useMemo(() => countFiles(filteredFiles), [filteredFiles]);
264
-
265
- // Keyboard shortcut for search (Cmd+K / Ctrl+K)
266
- useEffect(() => {
267
- const handleKeyDown = (e: KeyboardEvent) => {
268
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
269
- e.preventDefault();
270
- setShowSearch(true);
271
- setTimeout(() => searchInputRef.current?.focus(), 0);
272
- }
273
- if (e.key === 'Escape' && showSearch) {
274
- setShowSearch(false);
275
- setSearchQuery('');
276
- }
277
- };
278
- window.addEventListener('keydown', handleKeyDown);
279
- return () => window.removeEventListener('keydown', handleKeyDown);
280
- }, [showSearch]);
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) => {
305
- navigator.clipboard.writeText(path).then(() => {
306
- // Show temporary feedback
307
- const button = document.querySelector(`[data-file-path="${path}"]`) as HTMLElement;
308
- if (button) {
309
- const originalText = button.textContent;
310
- button.textContent = 'Copied!';
311
- setTimeout(() => {
312
- if (button) button.textContent = originalText;
313
- }, 1000);
314
- }
315
- });
316
- };
317
-
318
- const getFileUrl = (path: string) => {
319
- return `https://huggingface.co/${modelId}/resolve/main/${path}`;
320
- };
321
-
322
- const renderNode = (node: FileNode, depth: number = 0): React.ReactNode => {
323
- const isExpanded = expandedPaths.has(node.path);
324
- const hasChildren = node.children && node.children.length > 0;
325
- const fileName = node.path.split('/').pop() || node.path;
326
-
327
- return (
328
- <div key={node.path} className="file-tree-node" style={{ paddingLeft: `${depth * 1.5}rem` }}>
329
- <div
330
- className={`file-tree-item ${node.type} ${isExpanded ? 'expanded' : ''}`}
331
- onClick={() => node.type === 'directory' && toggleExpand(node.path)}
332
- style={{ cursor: node.type === 'directory' ? 'pointer' : 'default' }}
333
- >
334
- <span className="file-icon">{getFileIcon(node)}</span>
335
- <span className="file-name" title={node.path}>{fileName}</span>
336
- {node.type === 'file' && node.size && (
337
- <span className="file-size">{formatFileSize(node.size)}</span>
338
- )}
339
- {node.type === 'directory' && (
340
- <span className="file-expand">{isExpanded ? '▼' : '▶'}</span>
341
- )}
342
- {node.type === 'file' && (
343
- <div className="file-actions" onClick={(e) => e.stopPropagation()}>
344
- <button
345
- className="file-action-btn"
346
- onClick={() => copyFilePath(node.path)}
347
- data-file-path={node.path}
348
- title="Copy file path"
349
- aria-label="Copy path"
350
- >
351
- Copy
352
- </button>
353
- <a
354
- href={getFileUrl(node.path)}
355
- target="_blank"
356
- rel="noopener noreferrer"
357
- className="file-action-btn"
358
- title="Download file"
359
- aria-label="Download"
360
- onClick={(e) => e.stopPropagation()}
361
- >
362
- Download
363
- </a>
364
- </div>
365
- )}
366
- </div>
367
- {isExpanded && hasChildren && (
368
- <div className="file-tree-children">
369
- {node.children!.map((child) => renderNode(child, depth + 1))}
370
- </div>
371
- )}
372
- </div>
373
- );
374
- };
375
-
376
- if (loading) {
377
- return (
378
- <div className="file-tree-container">
379
- <div className="file-tree-loading">Loading file tree...</div>
380
- </div>
381
- );
382
- }
383
-
384
- if (error) {
385
- return (
386
- <div className="file-tree-container">
387
- <div className="file-tree-error">
388
- {error}
389
- <div style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: '#666' }}>
390
- File tree may not be available for this model.
391
- </div>
392
- </div>
393
- </div>
394
- );
395
- }
396
-
397
- if (files.length === 0) {
398
- return (
399
- <div className="file-tree-container">
400
- <div className="file-tree-empty">No files found</div>
401
- </div>
402
- );
403
- }
404
-
405
- const hasDirectories = files.some(node => node.type === 'directory');
406
-
407
- return (
408
- <div className="file-tree-container">
409
- <div className="file-tree-header">
410
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
411
- <strong>Repository Files</strong>
412
- <span className="file-count-badge">
413
- {visibleFileCount === totalFileCount
414
- ? `${totalFileCount} file${totalFileCount !== 1 ? 's' : ''}`
415
- : `${visibleFileCount} of ${totalFileCount} files`}
416
- </span>
417
- </div>
418
- <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
419
- <button
420
- onClick={() => setShowSearch(!showSearch)}
421
- className="file-tree-button"
422
- title="Search files (Cmd+K)"
423
- aria-label="Search"
424
- >
425
- 🔍 Search
426
- </button>
427
- {hasDirectories && (
428
- <>
429
- <button
430
- onClick={expandAll}
431
- className="file-tree-button"
432
- title="Expand all directories"
433
- aria-label="Expand all"
434
- >
435
- Expand All
436
- </button>
437
- <button
438
- onClick={collapseAll}
439
- className="file-tree-button"
440
- title="Collapse all directories"
441
- aria-label="Collapse all"
442
- >
443
- Collapse All
444
- </button>
445
- </>
446
- )}
447
- <a
448
- href={getHuggingFaceFileTreeUrl(modelId, 'main')}
449
- target="_blank"
450
- rel="noopener noreferrer"
451
- className="file-tree-link"
452
- >
453
- View on HF →
454
- </a>
455
- </div>
456
- </div>
457
-
458
- {/* Search and Filter Bar */}
459
- {(showSearch || searchQuery || fileTypeFilter !== 'all') && (
460
- <div className="file-tree-filters">
461
- <div className="file-tree-search">
462
- <input
463
- ref={searchInputRef}
464
- type="text"
465
- placeholder="Search files... (Cmd+K)"
466
- value={searchQuery}
467
- onChange={(e) => setSearchQuery(e.target.value)}
468
- className="file-tree-search-input"
469
- />
470
- {searchQuery && (
471
- <button
472
- onClick={() => setSearchQuery('')}
473
- className="file-tree-clear"
474
- aria-label="Clear search"
475
- >
476
-
477
- </button>
478
- )}
479
- </div>
480
- {getAllFileExtensions.length > 0 && (
481
- <select
482
- value={fileTypeFilter}
483
- onChange={(e) => setFileTypeFilter(e.target.value)}
484
- className="file-tree-type-filter"
485
- >
486
- <option value="all">All file types</option>
487
- {getAllFileExtensions.map(ext => (
488
- <option key={ext} value={ext}>.{ext}</option>
489
- ))}
490
- </select>
491
- )}
492
- </div>
493
- )}
494
-
495
- <div className="file-tree">
496
- {filteredFiles.length === 0 ? (
497
- <div className="file-tree-empty">
498
- {searchQuery || fileTypeFilter !== 'all'
499
- ? 'No files match your filters'
500
- : 'No files found'}
501
- </div>
502
- ) : (
503
- filteredFiles.map((node) => renderNode(node))
504
- )}
505
- </div>
506
- </div>
507
- );
508
- }
509
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/modals/ModelModal.css DELETED
@@ -1,533 +0,0 @@
1
- .modal-overlay {
2
- position: fixed;
3
- top: 0;
4
- left: 0;
5
- right: 0;
6
- bottom: 0;
7
- background: rgba(0, 0, 0, 0.6);
8
- display: flex;
9
- align-items: center;
10
- justify-content: center;
11
- z-index: 1000;
12
- padding: 2rem;
13
- animation: fadeIn 0.2s ease-in;
14
- }
15
-
16
- @keyframes fadeIn {
17
- from {
18
- opacity: 0;
19
- }
20
- to {
21
- opacity: 1;
22
- }
23
- }
24
-
25
- .modal-content {
26
- background: #ffffff;
27
- border-radius: 0;
28
- max-width: 900px;
29
- width: 100%;
30
- max-height: 90vh;
31
- overflow-y: auto;
32
- padding: 0;
33
- position: relative;
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
- }
41
-
42
- .modal-content[data-tab="files"] {
43
- max-width: 1000px;
44
- max-height: 95vh;
45
- }
46
-
47
- @keyframes slideUp {
48
- from {
49
- transform: translateY(20px);
50
- opacity: 0;
51
- }
52
- to {
53
- transform: translateY(0);
54
- opacity: 1;
55
- }
56
- }
57
-
58
- .modal-close {
59
- position: absolute;
60
- top: 1rem;
61
- right: 1rem;
62
- background: none;
63
- border: 1px solid #d0d0d0;
64
- font-size: 0.85rem;
65
- line-height: 1;
66
- cursor: pointer;
67
- color: #6a6a6a;
68
- padding: 0.4rem 0.8rem;
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 {
78
- background: #f0f0f0;
79
- color: #1a1a1a;
80
- }
81
-
82
- .modal-header {
83
- padding: 1.5rem 2rem;
84
- border-bottom: 1px solid #e0e0e0;
85
- display: flex;
86
- justify-content: space-between;
87
- align-items: flex-start;
88
- gap: 1rem;
89
- background: #fafafa;
90
- }
91
-
92
- .modal-content h2 {
93
- margin: 0;
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
- }
101
-
102
- .modal-body {
103
- padding: 1.5rem 2rem;
104
- flex: 1;
105
- overflow-y: auto;
106
- }
107
-
108
- .modal-actions {
109
- display: flex;
110
- gap: 0.5rem;
111
- flex-wrap: wrap;
112
- margin-bottom: 1.5rem;
113
- padding-bottom: 1.5rem;
114
- border-bottom: 1px solid #e0e0e0;
115
- }
116
-
117
- .action-btn {
118
- padding: 0.5rem 1rem;
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
- }
129
-
130
- .action-btn:hover {
131
- background: #4a4a4a;
132
- transform: translateY(-1px);
133
- }
134
-
135
- .action-btn.active {
136
- background: #4a4a4a;
137
- }
138
-
139
- .modal-tabs {
140
- display: flex;
141
- gap: 0.5rem;
142
- margin-bottom: 1.5rem;
143
- border-bottom: 2px solid #e0e0e0;
144
- position: sticky;
145
- top: 0;
146
- background: #ffffff;
147
- z-index: 10;
148
- padding-top: 0.5rem;
149
- margin-top: -0.5rem;
150
- }
151
-
152
- .modal-tab {
153
- padding: 0.75rem 1.5rem;
154
- background: none;
155
- border: none;
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;
163
- transition: all 0.2s;
164
- display: flex;
165
- align-items: center;
166
- gap: 0.5rem;
167
- position: relative;
168
- }
169
-
170
- .tab-icon {
171
- font-size: 1rem;
172
- }
173
-
174
- .tab-badge {
175
- background: #4a90e2;
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
- }
183
-
184
- .modal-tab:hover {
185
- color: #1a1a1a;
186
- background: #f5f5f5;
187
- }
188
-
189
- .modal-tab.active {
190
- color: #1a1a1a;
191
- border-bottom-color: #1a1a1a;
192
- font-weight: 600;
193
- }
194
-
195
- .modal-info-section {
196
- min-height: 200px;
197
- }
198
-
199
- .info-grid {
200
- display: grid;
201
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
202
- gap: 1.5rem;
203
- margin-bottom: 1.5rem;
204
- }
205
-
206
- .info-item {
207
- display: flex;
208
- flex-direction: column;
209
- gap: 0.5rem;
210
- }
211
-
212
- .info-label {
213
- font-size: 0.8rem;
214
- color: #666;
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 {
229
- font-size: 1.3rem;
230
- font-weight: 600;
231
- color: #1a1a1a;
232
- }
233
-
234
- .info-value.colored {
235
- font-size: 1.1rem;
236
- }
237
-
238
- .info-value.coordinates {
239
- display: flex;
240
- flex-direction: column;
241
- gap: 0.25rem;
242
- font-size: 0.95rem;
243
- font-family: 'Monaco', 'Menlo', monospace;
244
- color: #4a4a4a;
245
- }
246
-
247
- .info-section {
248
- margin-bottom: 1.5rem;
249
- padding: 1rem;
250
- background: #fafafa;
251
- border-radius: 0;
252
- border: 1px solid #e0e0e0;
253
- }
254
-
255
- .section-title {
256
- font-size: 0.85rem;
257
- color: #666;
258
- text-transform: uppercase;
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 {
266
- font-size: 0.95rem;
267
- color: #1a1a1a;
268
- line-height: 1.6;
269
- }
270
-
271
- .tag-list {
272
- display: flex;
273
- flex-wrap: wrap;
274
- gap: 0.5rem;
275
- }
276
-
277
- .tag {
278
- display: inline-block;
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;
286
- }
287
-
288
- .tag.license-tag {
289
- background: #fff3e0;
290
- border-color: #ffcc80;
291
- }
292
-
293
- .model-link {
294
- color: #4a90e2;
295
- text-decoration: none;
296
- font-weight: 500;
297
- word-break: break-all;
298
- }
299
-
300
- .model-link:hover {
301
- text-decoration: underline;
302
- }
303
-
304
- .modal-section {
305
- margin-bottom: 1.5rem;
306
- }
307
-
308
- .modal-section:last-child {
309
- margin-bottom: 0;
310
- }
311
-
312
- .modal-section h3 {
313
- margin: 0 0 0.75rem 0;
314
- font-size: 1rem;
315
- font-weight: 600;
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 {
323
- display: grid;
324
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
325
- gap: 1rem;
326
- }
327
-
328
- .modal-info-item {
329
- display: flex;
330
- flex-direction: column;
331
- gap: 0.25rem;
332
- }
333
-
334
- .modal-info-item strong {
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 {
360
- margin-top: 2rem;
361
- padding-top: 1.5rem;
362
- border-top: 1px solid #e0e0e0;
363
- display: flex;
364
- justify-content: center;
365
- }
366
-
367
- .modal-link {
368
- display: inline-flex;
369
- align-items: center;
370
- gap: 0.5rem;
371
- padding: 0.75rem 1.5rem;
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
- }
381
-
382
- .modal-link:hover {
383
- background: #4a4a4a;
384
- border-color: #4a4a4a;
385
- transform: translateY(-1px);
386
- }
387
-
388
- @media (max-width: 768px) {
389
- .modal-content {
390
- max-width: 100%;
391
- margin: 1rem;
392
- }
393
-
394
- .modal-header {
395
- padding: 1rem 1.5rem;
396
- }
397
-
398
- .modal-body {
399
- padding: 1rem 1.5rem;
400
- }
401
-
402
- .info-grid {
403
- grid-template-columns: 1fr;
404
- }
405
-
406
- .modal-tabs {
407
- overflow-x: auto;
408
- }
409
- }
410
-
411
- /* Papers Section */
412
- .papers-loading,
413
- .papers-error,
414
- .papers-empty {
415
- text-align: center;
416
- padding: 2rem;
417
- color: #666;
418
- }
419
-
420
- .papers-error {
421
- color: #d32f2f;
422
- background: #ffebee;
423
- border-radius: 0;
424
- padding: 1rem;
425
- }
426
-
427
- .papers-list {
428
- display: flex;
429
- flex-direction: column;
430
- gap: 1.5rem;
431
- }
432
-
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
- }
440
-
441
- .paper-card:hover {
442
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
443
- }
444
-
445
- .paper-header {
446
- margin-bottom: 1rem;
447
- }
448
-
449
- .paper-title {
450
- margin: 0 0 0.5rem 0;
451
- font-size: 1.25rem;
452
- line-height: 1.4;
453
- }
454
-
455
- .paper-link {
456
- color: #1976d2;
457
- text-decoration: none;
458
- transition: color 0.2s;
459
- }
460
-
461
- .paper-link:hover {
462
- color: #1565c0;
463
- text-decoration: underline;
464
- }
465
-
466
- .paper-id {
467
- font-size: 0.875rem;
468
- color: #666;
469
- }
470
-
471
- .arxiv-link {
472
- color: #b31b1b;
473
- text-decoration: none;
474
- font-weight: 500;
475
- transition: color 0.2s;
476
- }
477
-
478
- .arxiv-link:hover {
479
- color: #8b0000;
480
- text-decoration: underline;
481
- }
482
-
483
- .paper-authors,
484
- .paper-date,
485
- .paper-categories {
486
- margin-bottom: 0.75rem;
487
- font-size: 0.9rem;
488
- color: #555;
489
- }
490
-
491
- .paper-authors strong,
492
- .paper-date strong,
493
- .paper-categories strong {
494
- color: #333;
495
- margin-right: 0.5rem;
496
- }
497
-
498
- .paper-categories {
499
- display: flex;
500
- flex-wrap: wrap;
501
- align-items: center;
502
- gap: 0.5rem;
503
- }
504
-
505
- .category-tag {
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
- }
513
-
514
- .paper-abstract {
515
- margin-top: 1rem;
516
- padding-top: 1rem;
517
- border-top: 1px solid #e0e0e0;
518
- }
519
-
520
- .paper-abstract strong {
521
- display: block;
522
- margin-bottom: 0.5rem;
523
- color: #333;
524
- font-size: 0.95rem;
525
- }
526
-
527
- .paper-abstract p {
528
- margin: 0;
529
- line-height: 1.6;
530
- color: #555;
531
- text-align: justify;
532
- }
533
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/modals/ModelModal.tsx DELETED
@@ -1,428 +0,0 @@
1
- /**
2
- * Modal component for displaying detailed model information.
3
- * Enhanced with bookmark, comparison, similar models, and file tree features.
4
- */
5
- import React, { useState, useEffect } from 'react';
6
- import { ModelPoint } from '../../types';
7
- import FileTree from './FileTree';
8
- import { getHuggingFaceUrl } from '../../utils/api/hfUrl';
9
- import { API_BASE } from '../../config/api';
10
- import './ModelModal.css';
11
-
12
- interface ArxivPaper {
13
- arxiv_id: string;
14
- title: string;
15
- abstract: string;
16
- authors: string[];
17
- published: string;
18
- categories: string[];
19
- url: string;
20
- }
21
-
22
- interface ModelModalProps {
23
- model: ModelPoint | null;
24
- isOpen: boolean;
25
- onClose: () => void;
26
- onBookmark?: (modelId: string) => void;
27
- onAddToComparison?: (model: ModelPoint) => void;
28
- onLoadSimilar?: (modelId: string) => void;
29
- isBookmarked?: boolean;
30
- }
31
-
32
- export default function ModelModal({
33
- model,
34
- isOpen,
35
- onClose,
36
- onBookmark,
37
- onAddToComparison,
38
- onLoadSimilar,
39
- isBookmarked = false,
40
- }: ModelModalProps) {
41
- const [activeTab, setActiveTab] = useState<'details' | 'files' | 'papers'>('details');
42
- const [papers, setPapers] = useState<ArxivPaper[]>([]);
43
- const [papersLoading, setPapersLoading] = useState(false);
44
- const [papersError, setPapersError] = useState<string | null>(null);
45
-
46
- // Fetch arXiv papers when model changes
47
- useEffect(() => {
48
- if (!isOpen || !model) {
49
- setPapers([]);
50
- return;
51
- }
52
-
53
- const fetchPapers = async () => {
54
- setPapersLoading(true);
55
- setPapersError(null);
56
- try {
57
- const response = await fetch(`${API_BASE}/api/model/${encodeURIComponent(model.model_id)}/papers`);
58
- if (!response.ok) throw new Error('Failed to fetch papers');
59
- const data = await response.json();
60
- setPapers(data.papers || []);
61
- } catch (err) {
62
- setPapersError(err instanceof Error ? err.message : 'Failed to load papers');
63
- setPapers([]);
64
- } finally {
65
- setPapersLoading(false);
66
- }
67
- };
68
-
69
- fetchPapers();
70
- }, [model?.model_id, isOpen]);
71
-
72
- if (!isOpen || !model) return null;
73
-
74
- const hfUrl = getHuggingFaceUrl(model.model_id);
75
-
76
- // Parse tags if it's a string representation of an array
77
- const parseTags = (tags: string | null | undefined): string[] => {
78
- if (!tags) return [];
79
- try {
80
- // Try to parse as JSON array
81
- if (tags.startsWith('[') && tags.endsWith(']')) {
82
- // Replace single quotes with double quotes for valid JSON
83
- const jsonString = tags.replace(/'/g, '"');
84
- return JSON.parse(jsonString);
85
- }
86
- // Otherwise split by comma
87
- return tags.split(',').map(t => t.trim().replace(/['"]/g, ''));
88
- } catch {
89
- // If parsing fails, try to extract values from string representation
90
- try {
91
- // Handle cases like "['tag1', 'tag2']"
92
- const cleaned = tags.replace(/^\[|\]$/g, '').replace(/'/g, '"');
93
- const parsed = JSON.parse(`[${cleaned}]`);
94
- return Array.isArray(parsed) ? parsed : [tags];
95
- } catch {
96
- return [tags];
97
- }
98
- }
99
- };
100
-
101
- const tags = parseTags(model.tags);
102
-
103
- // Parse licenses - handle both JSON arrays and string representations with single quotes
104
- const parseLicenses = (licenses: string | null | undefined): string[] => {
105
- if (!licenses) return [];
106
- try {
107
- // If it's already a valid JSON array string, parse it
108
- if (licenses.startsWith('[') && licenses.endsWith(']')) {
109
- // Replace single quotes with double quotes for valid JSON
110
- const jsonString = licenses.replace(/'/g, '"');
111
- return JSON.parse(jsonString);
112
- }
113
- // Otherwise, treat as a single license string
114
- return [licenses];
115
- } catch {
116
- // If parsing fails, try to extract values from string representation
117
- try {
118
- // Handle cases like "['apache-2.0']" or "['license1', 'license2']"
119
- const cleaned = licenses.replace(/^\[|\]$/g, '').replace(/'/g, '"');
120
- const parsed = JSON.parse(`[${cleaned}]`);
121
- return Array.isArray(parsed) ? parsed : [licenses];
122
- } catch {
123
- // Last resort: return as single-item array
124
- return [licenses];
125
- }
126
- }
127
- };
128
-
129
- const licenses = parseLicenses(model.licenses);
130
-
131
- // Color coding functions
132
- const getLibraryColor = (library: string | null | undefined): string => {
133
- if (!library) return '#cccccc';
134
- const colors: Record<string, string> = {
135
- 'transformers': '#1f77b4',
136
- 'diffusers': '#ff7f0e',
137
- 'sentence-transformers': '#2ca02c',
138
- 'timm': '#d62728',
139
- 'speechbrain': '#9467bd',
140
- };
141
- return colors[library.toLowerCase()] || '#6a6a6a';
142
- };
143
-
144
- const getPipelineColor = (pipeline: string | null | undefined): string => {
145
- if (!pipeline || pipeline === 'Unknown') return '#cccccc';
146
- const colors: Record<string, string> = {
147
- 'text-classification': '#1f77b4',
148
- 'token-classification': '#ff7f0e',
149
- 'question-answering': '#2ca02c',
150
- 'summarization': '#d62728',
151
- 'translation': '#9467bd',
152
- 'text-generation': '#8c564b',
153
- };
154
- return colors[pipeline.toLowerCase()] || '#6a6a6a';
155
- };
156
-
157
- return (
158
- <div className="modal-overlay" onClick={onClose}>
159
- <div
160
- className="modal-content"
161
- onClick={(e) => e.stopPropagation()}
162
- data-tab={activeTab}
163
- >
164
- <div className="modal-header">
165
- <h2>{model.model_id}</h2>
166
- <button className="modal-close" onClick={onClose}>Close</button>
167
- </div>
168
-
169
- <div className="modal-body">
170
- {/* Action Buttons */}
171
- <div className="modal-actions">
172
- {onBookmark && (
173
- <button
174
- onClick={() => onBookmark(model.model_id)}
175
- className={`action-btn ${isBookmarked ? 'active' : ''}`}
176
- >
177
- {isBookmarked ? '✓ Bookmarked' : 'Bookmark'}
178
- </button>
179
- )}
180
- {onAddToComparison && (
181
- <button
182
- onClick={() => onAddToComparison(model)}
183
- className="action-btn"
184
- >
185
- Add to Comparison
186
- </button>
187
- )}
188
- {onLoadSimilar && (
189
- <button
190
- onClick={() => onLoadSimilar(model.model_id)}
191
- className="action-btn"
192
- >
193
- Find Similar Models
194
- </button>
195
- )}
196
- </div>
197
-
198
- {/* Tabs */}
199
- <div className="modal-tabs">
200
- <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) && (
215
- <button
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>
223
- )}
224
- </div>
225
-
226
- {/* Tab Content */}
227
- {activeTab === 'details' && (
228
- <div className="modal-info-section">
229
- <div className="info-grid">
230
- <div className="info-item">
231
- <div className="info-label">Library</div>
232
- <div
233
- className="info-value colored"
234
- style={{
235
- color: getLibraryColor(model.library_name),
236
- fontWeight: 600
237
- }}
238
- >
239
- {model.library_name || 'Unknown'}
240
- </div>
241
- </div>
242
-
243
- <div className="info-item">
244
- <div className="info-label">Pipeline / Task</div>
245
- <div
246
- className="info-value colored"
247
- style={{
248
- color: getPipelineColor(model.pipeline_tag),
249
- fontWeight: 600
250
- }}
251
- >
252
- {model.pipeline_tag || 'Unknown'}
253
- </div>
254
- </div>
255
-
256
- <div className="info-item">
257
- <div className="info-label">Downloads</div>
258
- <div className="info-value highlight">
259
- {model.downloads.toLocaleString()}
260
- </div>
261
- </div>
262
-
263
- <div className="info-item">
264
- <div className="info-label">Likes</div>
265
- <div className="info-value highlight">
266
- {model.likes.toLocaleString()}
267
- </div>
268
- </div>
269
-
270
- {model.trending_score !== null && (
271
- <div className="info-item">
272
- <div className="info-label">Trending Score</div>
273
- <div className="info-value">
274
- {model.trending_score.toFixed(2)}
275
- </div>
276
- </div>
277
- )}
278
-
279
- <div className="info-item">
280
- <div className="info-label">Coordinates</div>
281
- <div className="info-value coordinates">
282
- <span>X: {model.x.toFixed(3)}</span>
283
- <span>Y: {model.y.toFixed(3)}</span>
284
- <span>Z: {model.z.toFixed(3)}</span>
285
- </div>
286
- </div>
287
- </div>
288
-
289
- {model.parent_model && (
290
- <div className="info-section">
291
- <div className="section-title">Parent Model</div>
292
- <div className="section-content">
293
- <a
294
- href={getHuggingFaceUrl(model.parent_model)}
295
- target="_blank"
296
- rel="noopener noreferrer"
297
- className="model-link"
298
- >
299
- {model.parent_model}
300
- </a>
301
- </div>
302
- </div>
303
- )}
304
-
305
- {licenses.length > 0 && (
306
- <div className="info-section">
307
- <div className="section-title">License{licenses.length > 1 ? 's' : ''}</div>
308
- <div className="section-content">
309
- <div className="tag-list">
310
- {licenses.map((license: string, idx: number) => (
311
- <span key={idx} className="tag license-tag">
312
- {license}
313
- </span>
314
- ))}
315
- </div>
316
- </div>
317
- </div>
318
- )}
319
-
320
- {tags.length > 0 && (
321
- <div className="info-section">
322
- <div className="section-title">Tags</div>
323
- <div className="section-content">
324
- <div className="tag-list">
325
- {tags.map((tag: string, idx: number) => (
326
- <span key={idx} className="tag">
327
- {tag}
328
- </span>
329
- ))}
330
- </div>
331
- </div>
332
- </div>
333
- )}
334
- </div>
335
- )}
336
-
337
- {activeTab === 'files' && (
338
- <div className="modal-info-section">
339
- <FileTree modelId={model.model_id} />
340
- </div>
341
- )}
342
-
343
- {activeTab === 'papers' && (
344
- <div className="modal-info-section">
345
- {papersLoading ? (
346
- <div className="papers-loading">Loading papers...</div>
347
- ) : papersError ? (
348
- <div className="papers-error">Error loading papers: {papersError}</div>
349
- ) : papers.length === 0 ? (
350
- <div className="papers-empty">No arXiv papers found for this model.</div>
351
- ) : (
352
- <div className="papers-list">
353
- {papers.map((paper, idx) => (
354
- <div key={paper.arxiv_id || idx} className="paper-card">
355
- <div className="paper-header">
356
- <h3 className="paper-title">
357
- <a
358
- href={paper.url}
359
- target="_blank"
360
- rel="noopener noreferrer"
361
- className="paper-link"
362
- >
363
- {paper.title}
364
- </a>
365
- </h3>
366
- <div className="paper-id">
367
- <a
368
- href={paper.url}
369
- target="_blank"
370
- rel="noopener noreferrer"
371
- className="arxiv-link"
372
- >
373
- arXiv:{paper.arxiv_id}
374
- </a>
375
- </div>
376
- </div>
377
-
378
- {paper.authors && paper.authors.length > 0 && (
379
- <div className="paper-authors">
380
- <strong>Authors:</strong> {paper.authors.join(', ')}
381
- </div>
382
- )}
383
-
384
- {paper.published && (
385
- <div className="paper-date">
386
- <strong>Published:</strong> {new Date(paper.published).toLocaleDateString()}
387
- </div>
388
- )}
389
-
390
- {paper.categories && paper.categories.length > 0 && (
391
- <div className="paper-categories">
392
- <strong>Categories:</strong>{' '}
393
- {paper.categories.map((cat, i) => (
394
- <span key={i} className="category-tag">
395
- {cat}
396
- </span>
397
- ))}
398
- </div>
399
- )}
400
-
401
- {paper.abstract && (
402
- <div className="paper-abstract">
403
- <strong>Abstract:</strong>
404
- <p>{paper.abstract}</p>
405
- </div>
406
- )}
407
- </div>
408
- ))}
409
- </div>
410
- )}
411
- </div>
412
- )}
413
-
414
- <div className="modal-footer">
415
- <a
416
- href={hfUrl}
417
- target="_blank"
418
- rel="noopener noreferrer"
419
- className="modal-link"
420
- >
421
- View on Hugging Face →
422
- </a>
423
- </div>
424
- </div>
425
- </div>
426
- </div>
427
- );
428
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/ui/ColorLegend.css CHANGED
@@ -1,14 +1,28 @@
1
  .color-legend {
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;
9
  font-size: 0.875rem;
10
- max-height: 400px;
11
  overflow-y: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
  .legend-top-right {
@@ -32,11 +46,18 @@
32
  }
33
 
34
  .legend-header {
35
- margin-bottom: 0.5rem;
36
- padding-bottom: 0.5rem;
37
- border-bottom: 1px solid #e0e0e0;
38
- font-size: 0.9rem;
39
- color: #333;
 
 
 
 
 
 
 
40
  }
41
 
42
  .legend-content {
@@ -51,6 +72,23 @@
51
  gap: 0.25rem;
52
  max-height: 300px;
53
  overflow-y: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
 
56
  .legend-item {
@@ -58,24 +96,44 @@
58
  align-items: center;
59
  gap: 0.5rem;
60
  padding: 0.25rem 0;
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
 
63
  .legend-color {
64
- width: 16px;
65
- height: 16px;
66
- border-radius: 0;
67
- border: 1px solid #ccc;
68
  flex-shrink: 0;
 
 
 
 
 
 
69
  }
70
 
71
  .legend-label {
72
- color: #555;
73
  font-size: 0.8rem;
74
  white-space: nowrap;
75
  overflow: hidden;
76
  text-overflow: ellipsis;
77
  }
78
 
 
 
 
 
79
  .legend-continuous {
80
  display: flex;
81
  flex-direction: column;
@@ -84,10 +142,15 @@
84
 
85
  .legend-gradient {
86
  display: flex;
87
- height: 20px;
88
- border-radius: 0;
89
  overflow: hidden;
90
- border: 1px solid #ccc;
 
 
 
 
 
 
91
  }
92
 
93
  .legend-gradient-segment {
@@ -99,7 +162,12 @@
99
  display: flex;
100
  justify-content: space-between;
101
  font-size: 0.75rem;
102
- color: #666;
 
 
 
 
 
103
  }
104
 
105
  @media (max-width: 768px) {
 
1
  .color-legend {
2
  position: absolute;
3
+ background: var(--bg-elevated, #ffffff);
4
+ border: 1px solid var(--border-medium, #d0d0d0);
5
+ padding: 1rem;
6
+ box-shadow: var(--shadow-lg, 0 2px 8px rgba(0, 0, 0, 0.12));
 
7
  z-index: 100;
8
  font-size: 0.875rem;
9
+ max-height: 450px;
10
  overflow-y: auto;
11
+ backdrop-filter: blur(12px);
12
+ color: var(--text-primary, #1a1a1a);
13
+ transition: box-shadow var(--transition-base, 0.2s ease), border-color var(--transition-base, 0.2s ease);
14
+ }
15
+
16
+ [data-theme="dark"] .color-legend {
17
+ background: rgba(20, 20, 20, 0.96);
18
+ border: 1px solid rgba(255, 255, 255, 0.25);
19
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
20
+ color: #ffffff;
21
+ }
22
+
23
+ .color-legend:hover {
24
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
25
+ border-color: rgba(255, 255, 255, 0.3);
26
  }
27
 
28
  .legend-top-right {
 
46
  }
47
 
48
  .legend-header {
49
+ margin-bottom: 0.75rem;
50
+ padding-bottom: 0.75rem;
51
+ border-bottom: 1px solid var(--border-light, #e8e8e8);
52
+ font-size: 0.95rem;
53
+ color: var(--text-primary, #1a1a1a);
54
+ font-weight: 600;
55
+ letter-spacing: -0.01em;
56
+ }
57
+
58
+ [data-theme="dark"] .legend-header {
59
+ border-bottom: 1px solid rgba(255, 255, 255, 0.25);
60
+ color: #ffffff;
61
  }
62
 
63
  .legend-content {
 
72
  gap: 0.25rem;
73
  max-height: 300px;
74
  overflow-y: auto;
75
+ padding-right: 0.25rem;
76
+ }
77
+
78
+ .legend-categorical::-webkit-scrollbar {
79
+ width: 6px;
80
+ }
81
+
82
+ .legend-categorical::-webkit-scrollbar-track {
83
+ background: rgba(255, 255, 255, 0.1);
84
+ }
85
+
86
+ .legend-categorical::-webkit-scrollbar-thumb {
87
+ background: rgba(255, 255, 255, 0.3);
88
+ }
89
+
90
+ .legend-categorical::-webkit-scrollbar-thumb:hover {
91
+ background: rgba(255, 255, 255, 0.5);
92
  }
93
 
94
  .legend-item {
 
96
  align-items: center;
97
  gap: 0.5rem;
98
  padding: 0.25rem 0;
99
+ transition: opacity 0.2s;
100
+ }
101
+
102
+ .legend-item:hover {
103
+ opacity: 0.8;
104
+ }
105
+
106
+ .legend-item-more {
107
+ opacity: 0.7;
108
+ font-size: 0.75rem;
109
+ margin-top: 0.5rem;
110
  }
111
 
112
  .legend-color {
113
+ width: 18px;
114
+ height: 18px;
115
+ border: 1px solid var(--border-medium, #d0d0d0);
 
116
  flex-shrink: 0;
117
+ box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
118
+ }
119
+
120
+ [data-theme="dark"] .legend-color {
121
+ border: 1px solid rgba(255, 255, 255, 0.3);
122
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
123
  }
124
 
125
  .legend-label {
126
+ color: var(--text-secondary, #666666);
127
  font-size: 0.8rem;
128
  white-space: nowrap;
129
  overflow: hidden;
130
  text-overflow: ellipsis;
131
  }
132
 
133
+ [data-theme="dark"] .legend-label {
134
+ color: #e0e0e0;
135
+ }
136
+
137
  .legend-continuous {
138
  display: flex;
139
  flex-direction: column;
 
142
 
143
  .legend-gradient {
144
  display: flex;
145
+ height: 24px;
 
146
  overflow: hidden;
147
+ border: 1px solid var(--border-medium, #d0d0d0);
148
+ box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
149
+ }
150
+
151
+ [data-theme="dark"] .legend-gradient {
152
+ border: 1px solid rgba(255, 255, 255, 0.3);
153
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
154
  }
155
 
156
  .legend-gradient-segment {
 
162
  display: flex;
163
  justify-content: space-between;
164
  font-size: 0.75rem;
165
+ color: var(--text-secondary, #666666);
166
+ margin-top: 0.25rem;
167
+ }
168
+
169
+ [data-theme="dark"] .legend-labels {
170
+ color: #e0e0e0;
171
  }
172
 
173
  @media (max-width: 768px) {
frontend/src/components/ui/ColorLegend.tsx CHANGED
@@ -2,8 +2,8 @@
2
  * Interactive color legend component for visualizations.
3
  * Shows color mappings for categorical and continuous data.
4
  */
5
- import React from 'react';
6
- import { getCategoricalColorMap, getContinuousColorScale } from '../../utils/rendering/colors';
7
  import './ColorLegend.css';
8
 
9
  interface ColorLegendProps {
@@ -11,75 +11,160 @@ interface ColorLegendProps {
11
  data: any[];
12
  width?: number;
13
  position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
 
14
  }
15
 
16
- export default function ColorLegend({ colorBy, data, width = 200, position = 'top-right' }: ColorLegendProps) {
17
- if (!data || data.length === 0) return null;
 
 
 
 
18
 
19
- const isCategorical = colorBy === 'library_name' || colorBy === 'pipeline_tag' || colorBy === 'cluster_id';
20
-
21
- // Get unique categories or value range
22
- let legendItems: Array<{ label: string; color: string }> = [];
23
-
24
- if (isCategorical) {
25
- const categories = Array.from(new Set(data.map((d: any) => {
26
- if (colorBy === 'library_name') return d.library_name || 'unknown';
27
- if (colorBy === 'pipeline_tag') return d.pipeline_tag || 'unknown';
28
- if (colorBy === 'cluster_id') return d.cluster_id !== null ? `Cluster ${d.cluster_id}` : 'No cluster';
29
- return 'unknown';
30
- }))).sort();
31
-
32
- const colorScheme = colorBy === 'library_name' ? 'library' : colorBy === 'pipeline_tag' ? 'pipeline' : 'default';
33
- const colorMap = getCategoricalColorMap(categories, colorScheme);
34
 
35
- legendItems = categories.map(cat => ({
36
- label: cat,
37
- color: colorMap.get(cat) || '#808080'
38
- }));
39
- } else if (colorBy === 'family_depth') {
40
- const depths = data.map((d: any) => d.family_depth ?? 0);
41
- const maxDepth = Math.max(...depths, 1);
42
- const scale = getContinuousColorScale(0, maxDepth, 'plasma');
43
-
44
- // Create gradient legend
45
- const steps = 10;
46
- for (let i = 0; i <= steps; i++) {
47
- const depth = (i / steps) * maxDepth;
48
- legendItems.push({
49
- label: `Depth ${Math.round(depth)}`,
50
- color: scale(depth)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
- } else {
54
- // Continuous scale (downloads, likes)
55
- const values = data.map((d: any) => {
56
- if (colorBy === 'downloads') return d.downloads;
57
- return d.likes;
58
- });
59
- const min = Math.min(...values);
60
- const max = Math.max(...values);
61
- const scale = getContinuousColorScale(min, max, 'viridis');
62
 
63
- // Create gradient legend
64
- const steps = 10;
65
- for (let i = 0; i <= steps; i++) {
66
- const value = min + (i / steps) * (max - min);
67
- legendItems.push({
68
- label: value >= 1000 ? `${(value / 1000).toFixed(1)}K` : Math.round(value).toString(),
69
- color: scale(value)
70
- });
71
- }
72
- }
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- const positionClass = `legend-${position}`;
75
 
76
  return (
77
  <div className={`color-legend ${positionClass}`} style={{ width }}>
78
  <div className="legend-header">
79
- <strong>{colorBy.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}</strong>
80
  </div>
81
  <div className="legend-content">
82
- {isCategorical || colorBy === 'family_depth' ? (
83
  <div className="legend-categorical">
84
  {legendItems.map((item, idx) => (
85
  <div key={idx} className="legend-item">
@@ -87,9 +172,14 @@ export default function ColorLegend({ colorBy, data, width = 200, position = 'to
87
  className="legend-color"
88
  style={{ backgroundColor: item.color }}
89
  />
90
- <span className="legend-label">{item.label}</span>
91
  </div>
92
  ))}
 
 
 
 
 
93
  </div>
94
  ) : (
95
  <div className="legend-continuous">
@@ -103,8 +193,8 @@ export default function ColorLegend({ colorBy, data, width = 200, position = 'to
103
  ))}
104
  </div>
105
  <div className="legend-labels">
106
- <span>{legendItems[0].label}</span>
107
- <span>{legendItems[legendItems.length - 1].label}</span>
108
  </div>
109
  </div>
110
  )}
@@ -113,3 +203,11 @@ export default function ColorLegend({ colorBy, data, width = 200, position = 'to
113
  );
114
  }
115
 
 
 
 
 
 
 
 
 
 
2
  * Interactive color legend component for visualizations.
3
  * Shows color mappings for categorical and continuous data.
4
  */
5
+ import React, { useMemo } from 'react';
6
+ import { getCategoricalColorMap, getContinuousColorScale, getDepthColorScale } from '../../utils/rendering/colors';
7
  import './ColorLegend.css';
8
 
9
  interface ColorLegendProps {
 
11
  data: any[];
12
  width?: number;
13
  position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
14
+ isDarkMode?: boolean;
15
  }
16
 
17
+ function ColorLegend({ colorBy, data, width = 200, position = 'top-right', isDarkMode }: ColorLegendProps) {
18
+ // Memoize legend calculation to prevent recalculation on every render
19
+ const { legendItems, isContinuous, isCategorical, positionClass } = useMemo(() => {
20
+ if (!data || data.length === 0) {
21
+ return { legendItems: [], isContinuous: false, isCategorical: false, positionClass: `legend-${position}` };
22
+ }
23
 
24
+ let isCat = colorBy === 'library_name' || colorBy === 'pipeline_tag' || colorBy === 'cluster_id';
25
+ let items: Array<{ label: string; color: string }> = [];
26
+ let isCont = false;
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ if (isCat) {
29
+ const categories = Array.from(new Set(data.map((d: any) => {
30
+ if (colorBy === 'library_name') return d.library_name || 'unknown';
31
+ if (colorBy === 'pipeline_tag') return d.pipeline_tag || 'unknown';
32
+ if (colorBy === 'cluster_id') return d.cluster_id !== null ? `Cluster ${d.cluster_id}` : 'No cluster';
33
+ return 'unknown';
34
+ }))).sort();
35
+
36
+ const colorSchemeType = colorBy === 'library_name' ? 'library' : colorBy === 'pipeline_tag' ? 'pipeline' : 'default';
37
+ const colorMap = getCategoricalColorMap(categories, colorSchemeType);
38
+
39
+ // Limit to top 20 categories for readability
40
+ const topCategories = categories.slice(0, 20);
41
+ items = topCategories.map(cat => ({
42
+ label: cat.length > 25 ? cat.substring(0, 22) + '...' : cat,
43
+ color: colorMap.get(cat) || '#60a5fa'
44
+ }));
45
+ } else if (colorBy === 'family_depth') {
46
+ isCont = true;
47
+ const depths = data.map((d: any) => d.family_depth ?? 0);
48
+ const maxDepth = Math.max(...depths, 1);
49
+ const minDepth = Math.min(...depths);
50
+ const uniqueDepths = new Set(depths);
51
+
52
+ // Use dark mode state if provided, otherwise detect from document
53
+ const darkMode = isDarkMode !== undefined
54
+ ? isDarkMode
55
+ : document.documentElement.getAttribute('data-theme') === 'dark';
56
+
57
+ // If all depths are the same or very few unique depths, show a simpler legend
58
+ if (uniqueDepths.size <= 2 && maxDepth === 0) {
59
+ // All models are root - show library-based legend instead
60
+ const categories = Array.from(new Set(data.map((d: any) => d.library_name || 'unknown')));
61
+ const colorMap = getCategoricalColorMap(categories, 'library');
62
+ const topCategories = categories.slice(0, 10);
63
+ items = topCategories.map(cat => ({
64
+ label: cat.length > 20 ? cat.substring(0, 17) + '...' : cat,
65
+ color: colorMap.get(cat) || '#4a90e2'
66
+ }));
67
+ isCont = false;
68
+ isCat = true;
69
+ } else {
70
+ const scale = getDepthColorScale(maxDepth, darkMode);
71
+
72
+ // Create gradient legend showing depth progression
73
+ const steps = 8;
74
+ for (let i = 0; i <= steps; i++) {
75
+ const depth = (i / steps) * maxDepth;
76
+ let label = '';
77
+ if (i === 0) {
78
+ label = `${minDepth} (Root)`;
79
+ } else if (i === steps) {
80
+ label = `${Math.round(maxDepth)} (Deep)`;
81
+ } else if (i === Math.floor(steps / 2)) {
82
+ label = `${Math.round(depth)}`;
83
+ }
84
+ items.push({
85
+ label,
86
+ color: scale(depth)
87
+ });
88
+ }
89
+ }
90
+ } else if (colorBy === 'downloads' || colorBy === 'likes') {
91
+ isCont = true;
92
+ const values = data.map((d: any) => {
93
+ if (colorBy === 'downloads') return d.downloads;
94
+ return d.likes;
95
  });
96
+ const min = Math.min(...values);
97
+ const max = Math.max(...values);
98
+ const scale = getContinuousColorScale(min, max, 'viridis', true); // Use log scale
99
+
100
+ // Create gradient legend
101
+ const steps = 8;
102
+ for (let i = 0; i <= steps; i++) {
103
+ const logMin = Math.log10(min + 1);
104
+ const logMax = Math.log10(max + 1);
105
+ const logValue = logMin + (i / steps) * (logMax - logMin);
106
+ const value = Math.pow(10, logValue) - 1;
107
+
108
+ let label = '';
109
+ if (i === 0) {
110
+ label = min >= 1000 ? `${(min / 1000).toFixed(1)}K` : Math.round(min).toString();
111
+ } else if (i === steps) {
112
+ label = max >= 1000000 ? `${(max / 1000000).toFixed(1)}M` : max >= 1000 ? `${(max / 1000).toFixed(1)}K` : Math.round(max).toString();
113
+ }
114
+
115
+ items.push({
116
+ label,
117
+ color: scale(value)
118
+ });
119
+ }
120
+ } else if (colorBy === 'trending_score') {
121
+ isCont = true;
122
+ const scores = data.map((d: any) => d.trending_score ?? 0);
123
+ const min = Math.min(...scores);
124
+ const max = Math.max(...scores);
125
+ const scale = getContinuousColorScale(min, max, 'plasma', false);
126
+
127
+ const steps = 8;
128
+ for (let i = 0; i <= steps; i++) {
129
+ const value = min + (i / steps) * (max - min);
130
+ items.push({
131
+ label: i === 0 ? min.toFixed(1) : i === steps ? max.toFixed(1) : '',
132
+ color: scale(value)
133
+ });
134
+ }
135
  }
 
 
 
 
 
 
 
 
 
136
 
137
+ return {
138
+ legendItems: items,
139
+ isContinuous: isCont,
140
+ isCategorical: isCat,
141
+ positionClass: `legend-${position}`
142
+ };
143
+ }, [data, colorBy, position, isDarkMode]); // Only recalculate when data, colorBy, position, or isDarkMode changes
144
+
145
+ const getTitle = useMemo(() => {
146
+ const titles: Record<string, string> = {
147
+ 'library_name': 'Library',
148
+ 'pipeline_tag': 'Pipeline / Task',
149
+ 'cluster_id': 'Cluster',
150
+ 'family_depth': 'Family Depth',
151
+ 'downloads': 'Downloads',
152
+ 'likes': 'Likes',
153
+ 'trending_score': 'Trending Score',
154
+ 'licenses': 'License'
155
+ };
156
+ return titles[colorBy] || colorBy.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
157
+ }, [colorBy]);
158
 
159
+ if (!data || data.length === 0 || legendItems.length === 0) return null;
160
 
161
  return (
162
  <div className={`color-legend ${positionClass}`} style={{ width }}>
163
  <div className="legend-header">
164
+ <strong>{getTitle}</strong>
165
  </div>
166
  <div className="legend-content">
167
+ {isCategorical ? (
168
  <div className="legend-categorical">
169
  {legendItems.map((item, idx) => (
170
  <div key={idx} className="legend-item">
 
172
  className="legend-color"
173
  style={{ backgroundColor: item.color }}
174
  />
175
+ <span className="legend-label" title={item.label}>{item.label}</span>
176
  </div>
177
  ))}
178
+ {legendItems.length >= 20 && (
179
+ <div className="legend-item legend-item-more">
180
+ <span className="legend-label">... and more</span>
181
+ </div>
182
+ )}
183
  </div>
184
  ) : (
185
  <div className="legend-continuous">
 
193
  ))}
194
  </div>
195
  <div className="legend-labels">
196
+ <span>{legendItems[0].label || 'Min'}</span>
197
+ <span>{legendItems[legendItems.length - 1].label || 'Max'}</span>
198
  </div>
199
  </div>
200
  )}
 
203
  );
204
  }
205
 
206
+ // Memoize the component to prevent unnecessary re-renders
207
+ export default React.memo(ColorLegend, (prevProps, nextProps) => {
208
+ // Only re-render if colorBy changes or data length changes significantly
209
+ return prevProps.colorBy === nextProps.colorBy &&
210
+ prevProps.position === nextProps.position &&
211
+ prevProps.width === nextProps.width &&
212
+ Math.abs(prevProps.data.length - nextProps.data.length) < 100; // Allow small fluctuations
213
+ });
frontend/src/components/ui/ErrorBoundary.css ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ ERROR BOUNDARY
3
+ ============================================ */
4
+
5
+ .error-boundary-container {
6
+ padding: 2rem;
7
+ margin: 2rem;
8
+ background: var(--bg-secondary);
9
+ border: 1px solid var(--border-medium);
10
+ color: var(--text-primary);
11
+ }
12
+
13
+ .error-boundary-title {
14
+ margin-top: 0;
15
+ margin-bottom: 1rem;
16
+ color: #ef4444;
17
+ }
18
+
19
+ .error-boundary-message {
20
+ margin-bottom: 1rem;
21
+ color: var(--text-secondary);
22
+ }
23
+
24
+ .error-boundary-details {
25
+ margin-top: 1rem;
26
+ }
27
+
28
+ .error-boundary-summary {
29
+ cursor: pointer;
30
+ font-weight: bold;
31
+ color: var(--text-primary);
32
+ }
33
+
34
+ .error-boundary-stack {
35
+ margin-top: 0.5rem;
36
+ padding: 1rem;
37
+ background: var(--bg-primary);
38
+ border: 1px solid var(--border-light);
39
+ overflow-x: auto;
40
+ font-size: 0.875rem;
41
+ white-space: pre-wrap;
42
+ word-break: break-word;
43
+ font-family: var(--font-mono);
44
+ color: var(--text-secondary);
45
+ }
46
+
47
+ .error-boundary-reset-btn {
48
+ margin-top: 1rem;
49
+ padding: 0.5rem 1rem;
50
+ background: var(--bg-tertiary);
51
+ border: 1px solid var(--border-medium);
52
+ color: var(--text-primary);
53
+ cursor: pointer;
54
+ font-size: 0.875rem;
55
+ transition: all var(--transition-base);
56
+ }
57
+
58
+ .error-boundary-reset-btn:hover {
59
+ background: var(--bg-secondary);
60
+ border-color: var(--accent-blue);
61
+ }
62
+
frontend/src/components/ui/ErrorBoundary.tsx CHANGED
@@ -2,6 +2,7 @@
2
  * Error Boundary component to catch and display React errors gracefully
3
  */
4
  import React, { Component, ErrorInfo, ReactNode } from 'react';
 
5
 
6
  interface Props {
7
  children: ReactNode;
@@ -33,11 +34,6 @@ class ErrorBoundary extends Component<Props, State> {
33
  }
34
 
35
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
36
- // Log error to console in development
37
- if (process.env.NODE_ENV === 'development') {
38
- console.error('ErrorBoundary caught an error:', error, errorInfo);
39
- }
40
-
41
  this.setState({
42
  error,
43
  errorInfo,
@@ -59,52 +55,23 @@ class ErrorBoundary extends Component<Props, State> {
59
  }
60
 
61
  return (
62
- <div
63
- style={{
64
- padding: '2rem',
65
- margin: '2rem',
66
- background: '#ffebee',
67
- border: '1px solid #ffcdd2',
68
- borderRadius: '0',
69
- color: '#c62828',
70
- }}
71
- >
72
- <h2 style={{ marginTop: 0 }}>Something went wrong</h2>
73
- <p>
74
  An error occurred while rendering this component. Please try refreshing the page.
75
  </p>
76
  {process.env.NODE_ENV === 'development' && this.state.error && (
77
- <details style={{ marginTop: '1rem' }}>
78
- <summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
79
  Error Details (Development Only)
80
  </summary>
81
- <pre
82
- style={{
83
- marginTop: '0.5rem',
84
- padding: '1rem',
85
- background: '#fff',
86
- borderRadius: '0',
87
- overflow: 'auto',
88
- fontSize: '0.875rem',
89
- }}
90
- >
91
  {this.state.error.toString()}
92
  {this.state.errorInfo?.componentStack}
93
  </pre>
94
  </details>
95
  )}
96
- <button
97
- onClick={this.handleReset}
98
- style={{
99
- marginTop: '1rem',
100
- padding: '0.5rem 1rem',
101
- background: '#4a4a4a',
102
- color: 'white',
103
- border: 'none',
104
- borderRadius: '0',
105
- cursor: 'pointer',
106
- }}
107
- >
108
  Try Again
109
  </button>
110
  </div>
@@ -116,4 +83,3 @@ class ErrorBoundary extends Component<Props, State> {
116
  }
117
 
118
  export default ErrorBoundary;
119
-
 
2
  * Error Boundary component to catch and display React errors gracefully
3
  */
4
  import React, { Component, ErrorInfo, ReactNode } from 'react';
5
+ import './ErrorBoundary.css';
6
 
7
  interface Props {
8
  children: ReactNode;
 
34
  }
35
 
36
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
 
 
 
 
 
37
  this.setState({
38
  error,
39
  errorInfo,
 
55
  }
56
 
57
  return (
58
+ <div className="error-boundary-container">
59
+ <h2 className="error-boundary-title">Something went wrong</h2>
60
+ <p className="error-boundary-message">
 
 
 
 
 
 
 
 
 
61
  An error occurred while rendering this component. Please try refreshing the page.
62
  </p>
63
  {process.env.NODE_ENV === 'development' && this.state.error && (
64
+ <details className="error-boundary-details">
65
+ <summary className="error-boundary-summary">
66
  Error Details (Development Only)
67
  </summary>
68
+ <pre className="error-boundary-stack">
 
 
 
 
 
 
 
 
 
69
  {this.state.error.toString()}
70
  {this.state.errorInfo?.componentStack}
71
  </pre>
72
  </details>
73
  )}
74
+ <button className="error-boundary-reset-btn" onClick={this.handleReset}>
 
 
 
 
 
 
 
 
 
 
 
75
  Try Again
76
  </button>
77
  </div>
 
83
  }
84
 
85
  export default ErrorBoundary;
 
frontend/src/components/ui/IntroModal.css ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ INTRO MODAL - TOP LEFT ONBOARDING
3
+ ============================================ */
4
+
5
+ .intro-modal {
6
+ position: absolute;
7
+ top: 60px;
8
+ left: 20px;
9
+ width: 280px;
10
+ background: var(--bg-elevated, rgba(22, 27, 34, 0.98));
11
+ border: 1px solid var(--border-medium);
12
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
13
+ z-index: 1000;
14
+ font-family: var(--font-primary);
15
+ animation: introSlideIn 0.2s ease-out;
16
+ }
17
+
18
+ @keyframes introSlideIn {
19
+ from {
20
+ opacity: 0;
21
+ transform: translateY(-10px);
22
+ }
23
+ to {
24
+ opacity: 1;
25
+ transform: translateY(0);
26
+ }
27
+ }
28
+
29
+ .intro-header {
30
+ display: flex;
31
+ justify-content: space-between;
32
+ align-items: center;
33
+ padding: 10px 12px;
34
+ border-bottom: 1px solid var(--border-light);
35
+ background: var(--bg-secondary);
36
+ }
37
+
38
+ .intro-title {
39
+ font-size: 0.8rem;
40
+ font-weight: 600;
41
+ color: var(--text-primary);
42
+ text-transform: uppercase;
43
+ letter-spacing: 0.5px;
44
+ }
45
+
46
+ .intro-close {
47
+ background: transparent;
48
+ border: none;
49
+ color: var(--text-tertiary);
50
+ padding: 4px;
51
+ cursor: pointer;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ transition: all 0.15s ease;
56
+ }
57
+
58
+ .intro-close:hover {
59
+ color: var(--text-primary);
60
+ }
61
+
62
+ .intro-content {
63
+ padding: 12px;
64
+ }
65
+
66
+ .intro-desc {
67
+ margin: 0 0 12px 0;
68
+ font-size: 0.75rem;
69
+ color: var(--text-primary);
70
+ line-height: 1.5;
71
+ }
72
+
73
+ .intro-desc strong {
74
+ color: var(--accent-blue);
75
+ font-weight: 600;
76
+ }
77
+
78
+ .intro-desc-sub {
79
+ display: block;
80
+ margin-top: 4px;
81
+ font-size: 0.68rem;
82
+ color: var(--text-tertiary);
83
+ font-style: italic;
84
+ }
85
+
86
+ .intro-section {
87
+ margin-bottom: 10px;
88
+ }
89
+
90
+ .intro-section:last-child {
91
+ margin-bottom: 0;
92
+ }
93
+
94
+ .intro-section-title {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 6px;
98
+ font-size: 0.7rem;
99
+ font-weight: 600;
100
+ color: var(--text-primary);
101
+ text-transform: uppercase;
102
+ letter-spacing: 0.3px;
103
+ margin-bottom: 6px;
104
+ }
105
+
106
+ .intro-section-title svg {
107
+ color: var(--accent-blue);
108
+ }
109
+
110
+ .intro-list {
111
+ margin: 0;
112
+ padding: 0;
113
+ list-style: none;
114
+ }
115
+
116
+ .intro-list li {
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 6px;
120
+ font-size: 0.7rem;
121
+ color: var(--text-secondary);
122
+ padding: 2px 0;
123
+ }
124
+
125
+ .intro-list li strong {
126
+ color: var(--text-primary);
127
+ font-weight: 500;
128
+ }
129
+
130
+ .intro-list.compact li {
131
+ padding: 1px 0;
132
+ }
133
+
134
+ .intro-list.inline {
135
+ display: flex;
136
+ flex-wrap: wrap;
137
+ gap: 8px;
138
+ }
139
+
140
+ .intro-list.inline li {
141
+ padding: 0;
142
+ gap: 4px;
143
+ }
144
+
145
+ .intro-detail {
146
+ color: var(--text-tertiary);
147
+ font-size: 0.65rem;
148
+ margin-left: auto;
149
+ }
150
+
151
+ .intro-list li code {
152
+ background: var(--bg-tertiary);
153
+ padding: 1px 4px;
154
+ font-size: 0.65rem;
155
+ font-family: var(--font-mono);
156
+ color: var(--text-primary);
157
+ }
158
+
159
+ .intro-list li kbd {
160
+ display: inline-block;
161
+ padding: 1px 5px;
162
+ font-size: 0.6rem;
163
+ font-family: var(--font-mono);
164
+ background: var(--bg-tertiary);
165
+ border: 1px solid var(--border-light);
166
+ color: var(--text-primary);
167
+ }
168
+
169
+ .intro-inline-icon {
170
+ color: #f59e0b;
171
+ vertical-align: middle;
172
+ }
173
+
174
+ .intro-color {
175
+ width: 10px;
176
+ height: 10px;
177
+ flex-shrink: 0;
178
+ }
179
+
180
+ .intro-color.family {
181
+ background: linear-gradient(135deg, #22d3ee 0%, #facc15 50%, #f472b6 100%);
182
+ }
183
+
184
+ .intro-color.library {
185
+ background: linear-gradient(135deg, #60a5fa 0%, #4ade80 50%, #fb923c 100%);
186
+ }
187
+
188
+ .intro-color.task {
189
+ background: linear-gradient(135deg, #4ade80 0%, #22d3ee 50%, #c084fc 100%);
190
+ }
191
+
192
+ .intro-text {
193
+ margin: 0;
194
+ font-size: 0.7rem;
195
+ color: var(--text-secondary);
196
+ line-height: 1.4;
197
+ }
198
+
199
+ .intro-text kbd {
200
+ display: inline-block;
201
+ padding: 1px 5px;
202
+ font-size: 0.65rem;
203
+ font-family: var(--font-mono);
204
+ background: var(--bg-tertiary);
205
+ border: 1px solid var(--border-light);
206
+ color: var(--text-primary);
207
+ }
208
+
209
+ .intro-footer {
210
+ display: flex;
211
+ justify-content: space-between;
212
+ align-items: center;
213
+ padding: 10px 12px;
214
+ border-top: 1px solid var(--border-light);
215
+ background: var(--bg-secondary);
216
+ }
217
+
218
+ .intro-checkbox {
219
+ display: flex;
220
+ align-items: center;
221
+ gap: 6px;
222
+ font-size: 0.65rem;
223
+ color: var(--text-tertiary);
224
+ cursor: pointer;
225
+ }
226
+
227
+ .intro-checkbox input {
228
+ width: 12px;
229
+ height: 12px;
230
+ margin: 0;
231
+ cursor: pointer;
232
+ }
233
+
234
+ .intro-dismiss {
235
+ padding: 5px 12px;
236
+ background: var(--accent-blue);
237
+ border: none;
238
+ color: #ffffff;
239
+ font-size: 0.7rem;
240
+ font-weight: 500;
241
+ cursor: pointer;
242
+ transition: all 0.15s ease;
243
+ }
244
+
245
+ .intro-dismiss:hover {
246
+ background: #3b82f6;
247
+ }
248
+
249
+ /* Light theme */
250
+ [data-theme="light"] .intro-modal {
251
+ background: rgba(255, 255, 255, 0.98);
252
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
253
+ }
254
+
255
+ /* Responsive - hide on small screens */
256
+ @media (max-width: 768px) {
257
+ .intro-modal {
258
+ display: none;
259
+ }
260
+ }
261
+
262
+ /* Smaller screens - adjust position */
263
+ @media (max-width: 1200px) {
264
+ .intro-modal {
265
+ width: 260px;
266
+ }
267
+ }
268
+
frontend/src/components/ui/IntroModal.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Intro Modal - Brief onboarding guide for the dashboard
3
+ */
4
+ import React, { useState, useEffect } from 'react';
5
+ import { X, Palette, Maximize2, Search, Move3D, Sparkles } from 'lucide-react';
6
+ import './IntroModal.css';
7
+
8
+ interface IntroModalProps {
9
+ onClose: () => void;
10
+ }
11
+
12
+ export default function IntroModal({ onClose }: IntroModalProps) {
13
+ const [dontShowAgain, setDontShowAgain] = useState(false);
14
+
15
+ const handleClose = () => {
16
+ if (dontShowAgain) {
17
+ localStorage.setItem('hf-intro-dismissed', 'true');
18
+ }
19
+ onClose();
20
+ };
21
+
22
+ // Close on Escape
23
+ useEffect(() => {
24
+ const handleKeyDown = (e: KeyboardEvent) => {
25
+ if (e.key === 'Escape') {
26
+ handleClose();
27
+ }
28
+ };
29
+ document.addEventListener('keydown', handleKeyDown);
30
+ return () => document.removeEventListener('keydown', handleKeyDown);
31
+ }, [dontShowAgain]);
32
+
33
+ return (
34
+ <div className="intro-modal">
35
+ <div className="intro-header">
36
+ <span className="intro-title">Quick Guide</span>
37
+ <button className="intro-close" onClick={handleClose} title="Close">
38
+ <X size={14} />
39
+ </button>
40
+ </div>
41
+
42
+ <div className="intro-content">
43
+ <p className="intro-desc">
44
+ Visualizing <strong>2M+ ML models</strong> from Hugging Face by text embeddings.
45
+ <br />
46
+ <span className="intro-desc-sub">Each point represents a model positioned by semantic similarity.</span>
47
+ </p>
48
+
49
+ <div className="intro-section">
50
+ <div className="intro-section-title">
51
+ <Palette size={12} />
52
+ Colors
53
+ </div>
54
+ <ul className="intro-list">
55
+ <li title="Colors based on how many generations a model is from its root parent">
56
+ <span className="intro-color family"></span>
57
+ <strong>Family</strong>
58
+ <span className="intro-detail">Lineage depth</span>
59
+ </li>
60
+ <li title="Colors based on which ML framework/library the model uses">
61
+ <span className="intro-color library"></span>
62
+ <strong>Library</strong>
63
+ <span className="intro-detail">ML framework</span>
64
+ </li>
65
+ <li title="Colors based on what the model does">
66
+ <span className="intro-color task"></span>
67
+ <strong>Task</strong>
68
+ <span className="intro-detail">Model type</span>
69
+ </li>
70
+ </ul>
71
+ </div>
72
+
73
+ <div className="intro-section">
74
+ <div className="intro-section-title">
75
+ <Maximize2 size={12} />
76
+ Size
77
+ </div>
78
+ <p className="intro-text">Larger points = more downloads/likes</p>
79
+ </div>
80
+
81
+ <div className="intro-section">
82
+ <div className="intro-section-title">
83
+ <Move3D size={12} />
84
+ Controls
85
+ </div>
86
+ <ul className="intro-list compact inline">
87
+ <li><strong>Drag</strong> rotate</li>
88
+ <li><strong>Scroll</strong> zoom</li>
89
+ <li><strong>Click</strong> select</li>
90
+ </ul>
91
+ </div>
92
+
93
+ <div className="intro-section">
94
+ <div className="intro-section-title">
95
+ <Search size={12} />
96
+ Search
97
+ </div>
98
+ <ul className="intro-list compact">
99
+ <li><kbd>⌘K</kbd> open search</li>
100
+ <li><Sparkles size={10} className="intro-inline-icon" /> Fuzzy: <code>lama</code> finds llama</li>
101
+ </ul>
102
+ </div>
103
+ </div>
104
+
105
+ <div className="intro-footer">
106
+ <label className="intro-checkbox">
107
+ <input
108
+ type="checkbox"
109
+ checked={dontShowAgain}
110
+ onChange={(e) => setDontShowAgain(e.target.checked)}
111
+ />
112
+ <span>Don't show again</span>
113
+ </label>
114
+ <button className="intro-dismiss" onClick={handleClose}>
115
+ Got it
116
+ </button>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
frontend/src/components/ui/LiveModelCount.css CHANGED
@@ -68,10 +68,44 @@
68
  opacity: 0.9;
69
  }
70
 
 
 
 
 
 
 
71
  .count-error {
72
  color: #ffcccc;
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  /* Full version (for sidebar) - matches sidebar-section style */
76
  .live-model-count-full {
77
  background: white;
 
68
  opacity: 0.9;
69
  }
70
 
71
+ .count-loading-full {
72
+ text-align: center;
73
+ padding: 1rem;
74
+ color: var(--text-tertiary);
75
+ }
76
+
77
  .count-error {
78
  color: #ffcccc;
79
  }
80
 
81
+ .count-error-full {
82
+ padding: 1rem;
83
+ background: var(--bg-secondary);
84
+ border: 1px solid var(--border-light);
85
+ color: var(--text-secondary);
86
+ text-align: center;
87
+ }
88
+
89
+ .count-error-message {
90
+ margin-bottom: 0.5rem;
91
+ color: #ef4444;
92
+ }
93
+
94
+ .count-retry-btn {
95
+ background: var(--bg-tertiary);
96
+ border: 1px solid var(--border-medium);
97
+ padding: 0.5rem 1rem;
98
+ cursor: pointer;
99
+ font-size: 0.875rem;
100
+ color: var(--text-primary);
101
+ transition: all var(--transition-base);
102
+ }
103
+
104
+ .count-retry-btn:hover {
105
+ background: var(--bg-secondary);
106
+ border-color: var(--accent-blue);
107
+ }
108
+
109
  /* Full version (for sidebar) - matches sidebar-section style */
110
  .live-model-count-full {
111
  background: white;
frontend/src/components/ui/LiveModelCount.tsx CHANGED
@@ -91,25 +91,15 @@ export default function LiveModelCount({ compact = true }: { compact?: boolean }
91
  ) : error && !currentCount ? (
92
  <div className="count-error" title={error}>Error</div>
93
  ) : currentCount ? (
94
- <>
95
- <div className="count-badge">
96
- <span className="count-label">Live Models:</span>
97
- <span className="count-value">{formatNumber(currentCount.total_models)}</span>
98
- {lastUpdate && (
99
- <span className="count-update" title={new Date(currentCount.timestamp).toLocaleString()}>
100
- {getTimeAgo(lastUpdate)}
101
- </span>
102
- )}
103
- </div>
104
- <button
105
- className="count-refresh-btn"
106
- onClick={fetchCurrentCount}
107
- disabled={loading}
108
- title="Refresh count"
109
- >
110
-
111
- </button>
112
- </>
113
  ) : null}
114
  </div>
115
  );
@@ -120,39 +110,13 @@ export default function LiveModelCount({ compact = true }: { compact?: boolean }
120
  <div className="live-model-count-full">
121
  <div className="count-header">
122
  <h4>Live Model Count</h4>
123
- <button
124
- className="refresh-btn-small"
125
- onClick={fetchCurrentCount}
126
- disabled={loading}
127
- title="Refresh"
128
- >
129
-
130
- </button>
131
  </div>
132
  {loading && !currentCount ? (
133
- <div className="count-loading" style={{ textAlign: 'center', padding: '1rem', color: '#666' }}>Loading...</div>
134
  ) : error && !currentCount ? (
135
- <div className="count-error" style={{
136
- padding: '1rem',
137
- background: '#ffebee',
138
- border: '1px solid #ffcdd2',
139
- borderRadius: '0',
140
- color: '#c62828',
141
- textAlign: 'center'
142
- }}>
143
- <div style={{ marginBottom: '0.5rem' }}>Error: {error}</div>
144
- <button
145
- onClick={fetchCurrentCount}
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',
153
- color: '#333'
154
- }}
155
- >
156
  Retry
157
  </button>
158
  </div>
 
91
  ) : error && !currentCount ? (
92
  <div className="count-error" title={error}>Error</div>
93
  ) : currentCount ? (
94
+ <div className="count-badge">
95
+ <span className="count-label">Live Models:</span>
96
+ <span className="count-value">{formatNumber(currentCount.total_models)}</span>
97
+ {lastUpdate && (
98
+ <span className="count-update" title={new Date(currentCount.timestamp).toLocaleString()}>
99
+ {getTimeAgo(lastUpdate)}
100
+ </span>
101
+ )}
102
+ </div>
 
 
 
 
 
 
 
 
 
 
103
  ) : null}
104
  </div>
105
  );
 
110
  <div className="live-model-count-full">
111
  <div className="count-header">
112
  <h4>Live Model Count</h4>
 
 
 
 
 
 
 
 
113
  </div>
114
  {loading && !currentCount ? (
115
+ <div className="count-loading-full">Loading...</div>
116
  ) : error && !currentCount ? (
117
+ <div className="count-error-full">
118
+ <div className="count-error-message">Error: {error}</div>
119
+ <button className="count-retry-btn" onClick={fetchCurrentCount}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  Retry
121
  </button>
122
  </div>
frontend/src/components/ui/LiveModelCounter.css ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ LIVE MODEL COUNTER - TOP LEFT DISPLAY
3
+ ============================================ */
4
+
5
+ .live-model-counter {
6
+ position: absolute;
7
+ top: 20px;
8
+ left: 20px;
9
+ background: var(--bg-elevated, rgba(26, 26, 26, 0.95));
10
+ border: 1px solid var(--border-medium, rgba(255, 255, 255, 0.15));
11
+ padding: 12px 16px;
12
+ min-width: 200px;
13
+ z-index: 500;
14
+ font-family: var(--font-primary);
15
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
16
+ transition: all var(--transition-base);
17
+ }
18
+
19
+ .live-model-counter:hover {
20
+ border-color: var(--accent-blue);
21
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
22
+ }
23
+
24
+ /* Pulse animation when new models detected */
25
+ .live-model-counter.pulse {
26
+ animation: counterPulse 0.6s ease-out;
27
+ }
28
+
29
+ @keyframes counterPulse {
30
+ 0% {
31
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
32
+ border-color: var(--border-medium);
33
+ }
34
+ 50% {
35
+ box-shadow: 0 0 20px rgba(74, 144, 226, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
36
+ border-color: var(--accent-blue);
37
+ }
38
+ 100% {
39
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
40
+ border-color: var(--border-medium);
41
+ }
42
+ }
43
+
44
+ /* Main row with icon, value, and refresh */
45
+ .counter-main {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 10px;
49
+ }
50
+
51
+ .counter-content {
52
+ flex: 1;
53
+ }
54
+
55
+ .counter-value {
56
+ font-size: 1.5rem;
57
+ font-weight: 700;
58
+ color: var(--text-primary, #ffffff);
59
+ font-variant-numeric: tabular-nums;
60
+ letter-spacing: -0.02em;
61
+ line-height: 1.2;
62
+ }
63
+
64
+ .counter-label {
65
+ font-size: 0.7rem;
66
+ color: var(--text-tertiary, rgba(255, 255, 255, 0.6));
67
+ text-transform: uppercase;
68
+ letter-spacing: 0.5px;
69
+ margin-top: 2px;
70
+ }
71
+
72
+ /* Growth stats */
73
+ .counter-growth {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 4px;
77
+ margin-top: 8px;
78
+ padding-top: 8px;
79
+ border-top: 1px solid var(--border-light);
80
+ font-size: 0.75rem;
81
+ color: #10b981;
82
+ }
83
+
84
+ .counter-growth svg {
85
+ flex-shrink: 0;
86
+ }
87
+
88
+ /* New models indicator */
89
+ .counter-new-models {
90
+ margin-top: 6px;
91
+ font-size: 0.7rem;
92
+ color: var(--accent-blue);
93
+ font-weight: 500;
94
+ }
95
+
96
+ /* Timestamp */
97
+ .counter-timestamp {
98
+ margin-top: 6px;
99
+ font-size: 0.65rem;
100
+ color: var(--text-tertiary);
101
+ font-style: italic;
102
+ }
103
+
104
+ /* Loading state */
105
+ .counter-loading {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 6px;
109
+ color: var(--text-secondary);
110
+ font-size: 0.85rem;
111
+ }
112
+
113
+ .counter-loading .spin {
114
+ animation: spin 1s linear infinite;
115
+ }
116
+
117
+ /* Error state */
118
+ .live-model-counter.counter-error {
119
+ border-color: #ef4444;
120
+ color: #ef4444;
121
+ }
122
+
123
+ /* Light theme adjustments */
124
+ [data-theme="light"] .live-model-counter {
125
+ background: rgba(255, 255, 255, 0.95);
126
+ border-color: var(--border-medium);
127
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
128
+ }
129
+
130
+ [data-theme="light"] .counter-value {
131
+ color: var(--text-primary);
132
+ }
133
+
134
+ [data-theme="light"] .counter-label {
135
+ color: var(--text-secondary);
136
+ }
137
+
138
+ /* Responsive - hide on very small screens */
139
+ @media (max-width: 480px) {
140
+ .live-model-counter {
141
+ top: 10px;
142
+ left: 10px;
143
+ padding: 8px 12px;
144
+ min-width: 160px;
145
+ }
146
+
147
+ .counter-value {
148
+ font-size: 1.2rem;
149
+ }
150
+
151
+ .counter-growth,
152
+ .counter-new-models,
153
+ .counter-timestamp {
154
+ display: none;
155
+ }
156
+ }
157
+
158
+ /* Responsive - smaller on mobile */
159
+ @media (max-width: 768px) {
160
+ .live-model-counter {
161
+ left: 10px;
162
+ top: 10px;
163
+ }
164
+ }
165
+
frontend/src/components/ui/LiveModelCounter.tsx ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Live model counter component for bottom-left display.
3
+ * Shows real-time model count from Hugging Face Hub with pulse animation.
4
+ * Supports continual updates via polling.
5
+ */
6
+ import React, { useState, useEffect, useCallback } from 'react';
7
+ import { TrendingUp, RefreshCw } from 'lucide-react';
8
+ import { API_BASE } from '../../config/api';
9
+ import './LiveModelCounter.css';
10
+
11
+ interface ModelCountData {
12
+ total_models: number;
13
+ timestamp: string;
14
+ source?: string;
15
+ models_by_library?: Record<string, number>;
16
+ models_by_pipeline?: Record<string, number>;
17
+ }
18
+
19
+ interface GrowthStats {
20
+ daily_growth_avg?: number;
21
+ growth_rate_percent?: number;
22
+ total_growth?: number;
23
+ period_days?: number;
24
+ }
25
+
26
+ interface LiveModelCounterProps {
27
+ pollInterval?: number; // in milliseconds, default 60 seconds
28
+ showGrowth?: boolean;
29
+ onNewModelsDetected?: (count: number, previousCount: number) => void;
30
+ }
31
+
32
+ export default function LiveModelCounter({
33
+ pollInterval = 60000,
34
+ showGrowth = true,
35
+ onNewModelsDetected,
36
+ }: LiveModelCounterProps) {
37
+ const [currentCount, setCurrentCount] = useState<ModelCountData | null>(null);
38
+ const [previousCount, setPreviousCount] = useState<number | null>(null);
39
+ const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
40
+ const [loading, setLoading] = useState(true);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
43
+ const [isRefreshing, setIsRefreshing] = useState(false);
44
+ const [newModelsAdded, setNewModelsAdded] = useState<number>(0);
45
+ const [showPulse, setShowPulse] = useState(false);
46
+
47
+ const fetchCurrentCount = useCallback(async (isManual = false) => {
48
+ if (isManual) setIsRefreshing(true);
49
+
50
+ try {
51
+ // Try primary endpoint first
52
+ let data: ModelCountData | null = null;
53
+
54
+ try {
55
+ const response = await fetch(
56
+ `${API_BASE}/api/model-count/current?use_models_page=true&use_cache=${!isManual}`
57
+ );
58
+ if (response.ok) {
59
+ data = await response.json();
60
+ }
61
+ } catch {
62
+ // Primary endpoint failed, will try fallback
63
+ }
64
+
65
+ // Fallback to stats endpoint
66
+ if (!data || !data.total_models) {
67
+ const statsResponse = await fetch(`${API_BASE}/api/stats`);
68
+ if (statsResponse.ok) {
69
+ const statsData = await statsResponse.json();
70
+ if (statsData.total_models) {
71
+ data = {
72
+ total_models: statsData.total_models,
73
+ timestamp: new Date().toISOString(),
74
+ source: 'stats'
75
+ };
76
+ }
77
+ }
78
+ }
79
+
80
+ if (!data || !data.total_models) {
81
+ throw new Error('No model count available');
82
+ }
83
+
84
+ // Check if new models were added
85
+ if (currentCount && data.total_models > currentCount.total_models) {
86
+ const newCount = data.total_models - currentCount.total_models;
87
+ setNewModelsAdded(prev => prev + newCount);
88
+ setShowPulse(true);
89
+ setTimeout(() => setShowPulse(false), 2000);
90
+
91
+ if (onNewModelsDetected) {
92
+ onNewModelsDetected(data.total_models, currentCount.total_models);
93
+ }
94
+ }
95
+
96
+ setPreviousCount(currentCount?.total_models || null);
97
+ setCurrentCount(data);
98
+ setLastUpdate(new Date());
99
+ setError(null);
100
+ } catch (err) {
101
+ setError(err instanceof Error ? err.message : 'Unknown error');
102
+ } finally {
103
+ setLoading(false);
104
+ setIsRefreshing(false);
105
+ }
106
+ }, [currentCount, onNewModelsDetected]);
107
+
108
+ const fetchGrowthStats = useCallback(async () => {
109
+ try {
110
+ const response = await fetch(`${API_BASE}/api/model-count/growth?days=7`);
111
+ if (response.ok) {
112
+ const data = await response.json();
113
+ setGrowthStats(data);
114
+ }
115
+ } catch {
116
+ // Silently fail - growth stats are optional
117
+ }
118
+ }, []);
119
+
120
+ // Initial fetch
121
+ useEffect(() => {
122
+ fetchCurrentCount();
123
+ if (showGrowth) {
124
+ fetchGrowthStats();
125
+ }
126
+ }, []);
127
+
128
+ // Polling interval
129
+ useEffect(() => {
130
+ const interval = setInterval(() => {
131
+ fetchCurrentCount();
132
+ }, pollInterval);
133
+
134
+ return () => clearInterval(interval);
135
+ }, [pollInterval, fetchCurrentCount]);
136
+
137
+ // Refresh growth stats less frequently
138
+ useEffect(() => {
139
+ if (!showGrowth) return;
140
+
141
+ const growthInterval = setInterval(() => {
142
+ fetchGrowthStats();
143
+ }, 5 * 60 * 1000); // Every 5 minutes
144
+
145
+ return () => clearInterval(growthInterval);
146
+ }, [showGrowth, fetchGrowthStats]);
147
+
148
+ const formatNumber = (num: number): string => {
149
+ if (num >= 1000000) {
150
+ return `${(num / 1000000).toFixed(2)}M`;
151
+ }
152
+ return new Intl.NumberFormat('en-US').format(num);
153
+ };
154
+
155
+ const formatLargeNumber = (num: number): string => {
156
+ return new Intl.NumberFormat('en-US').format(num);
157
+ };
158
+
159
+ const getTimeAgo = (date: Date): string => {
160
+ const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
161
+ if (seconds < 60) return 'just now';
162
+ const minutes = Math.floor(seconds / 60);
163
+ if (minutes < 60) return `${minutes}m ago`;
164
+ const hours = Math.floor(minutes / 60);
165
+ if (hours < 24) return `${hours}h ago`;
166
+ const days = Math.floor(hours / 24);
167
+ return `${days}d ago`;
168
+ };
169
+
170
+ if (loading && !currentCount) {
171
+ return (
172
+ <div className="live-model-counter">
173
+ <div className="counter-loading">
174
+ <RefreshCw size={14} className="spin" />
175
+ <span>Loading...</span>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ if (error && !currentCount) {
182
+ return (
183
+ <div className="live-model-counter counter-error">
184
+ <span>Error loading count</span>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ return (
190
+ <div
191
+ className={`live-model-counter ${showPulse ? 'pulse' : ''}`}
192
+ title="Total number of public models available on the Hugging Face Hub. Updates periodically."
193
+ >
194
+ <div className="counter-main">
195
+ <div className="counter-content">
196
+ <div className="counter-value" title={currentCount ? `Exact count: ${currentCount.total_models.toLocaleString()} models` : undefined}>
197
+ {currentCount ? formatLargeNumber(currentCount.total_models) : '—'}
198
+ </div>
199
+ <div className="counter-label">
200
+ Models on Hugging Face
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ {showGrowth && growthStats && growthStats.daily_growth_avg && (
206
+ <div className="counter-growth">
207
+ <TrendingUp size={12} />
208
+ <span>+{Math.round(growthStats.daily_growth_avg).toLocaleString()}/day</span>
209
+ </div>
210
+ )}
211
+
212
+ {newModelsAdded > 0 && (
213
+ <div className="counter-new-models">
214
+ +{newModelsAdded.toLocaleString()} new this session
215
+ </div>
216
+ )}
217
+
218
+ {lastUpdate && (
219
+ <div className="counter-timestamp">
220
+ Updated {getTimeAgo(lastUpdate)}
221
+ </div>
222
+ )}
223
+ </div>
224
+ );
225
+ }
226
+
frontend/src/components/ui/LoadingProgress.css ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .loading-progress {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ width: 100%;
6
+ height: 100%;
7
+ min-height: 400px;
8
+ padding: 2rem;
9
+ }
10
+
11
+ .loading-progress-content {
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ gap: 1.5rem;
16
+ max-width: 500px;
17
+ text-align: center;
18
+ }
19
+
20
+ .loading-spinner {
21
+ width: 16px;
22
+ height: 16px;
23
+ border: 2px solid var(--border-light, #e0e0e0);
24
+ border-top-color: var(--accent-primary, #4a90e2);
25
+ border-radius: 50%;
26
+ animation: spin 0.8s linear infinite;
27
+ }
28
+
29
+ @keyframes spin {
30
+ to {
31
+ transform: rotate(360deg);
32
+ }
33
+ }
34
+
35
+ .loading-message {
36
+ font-size: 1.1rem;
37
+ font-weight: 600;
38
+ color: var(--text-primary, #333);
39
+ }
40
+
41
+ .loading-submessage {
42
+ font-size: 0.9rem;
43
+ color: var(--text-secondary, #666);
44
+ font-weight: 400;
45
+ }
46
+
47
+ .loading-bar-container {
48
+ width: 100%;
49
+ height: 8px;
50
+ background: var(--bg-tertiary, #f5f5f5);
51
+ border: 1px solid var(--border-light, #e0e0e0);
52
+ border-radius: 0;
53
+ overflow: hidden;
54
+ margin-top: 0.5rem;
55
+ }
56
+
57
+ .loading-bar {
58
+ height: 100%;
59
+ background: var(--accent-primary, #4a90e2);
60
+ transition: width 0.3s ease;
61
+ border-radius: 0;
62
+ }
63
+
frontend/src/components/ui/LoadingProgress.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import './LoadingProgress.css';
3
+
4
+ interface LoadingProgressProps {
5
+ message?: string;
6
+ progress?: number; // 0-100
7
+ subMessage?: string;
8
+ }
9
+
10
+ export default function LoadingProgress({
11
+ message = 'Loading models...',
12
+ progress,
13
+ subMessage
14
+ }: LoadingProgressProps) {
15
+ return (
16
+ <div className="loading-progress">
17
+ <div className="loading-progress-content">
18
+ <div className="loading-spinner" />
19
+ <div className="loading-message">{message}</div>
20
+ {subMessage && (
21
+ <div className="loading-submessage">{subMessage}</div>
22
+ )}
23
+ {progress !== undefined && (
24
+ <div className="loading-bar-container">
25
+ <div
26
+ className="loading-bar"
27
+ style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
28
+ />
29
+ </div>
30
+ )}
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
frontend/src/components/ui/ModelCountTracker.tsx CHANGED
@@ -48,8 +48,8 @@ export default function ModelCountTracker() {
48
  if (!response.ok) throw new Error('Failed to fetch growth stats');
49
  const data = await response.json();
50
  setGrowthStats(data);
51
- } catch (err) {
52
- console.error('Error fetching growth stats:', err);
53
  }
54
  };
55
 
 
48
  if (!response.ok) throw new Error('Failed to fetch growth stats');
49
  const data = await response.json();
50
  setGrowthStats(data);
51
+ } catch {
52
+ // Silent error handling
53
  }
54
  };
55
 
frontend/src/components/ui/ModelPopup.css ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ MODEL POPUP - BOTTOM LEFT COMPACT VIEW
3
+ ============================================ */
4
+
5
+ .model-popup {
6
+ position: absolute;
7
+ bottom: 20px;
8
+ left: 20px;
9
+ width: 320px;
10
+ max-height: calc(100% - 80px);
11
+ background: var(--bg-elevated, rgba(26, 26, 26, 0.98));
12
+ border: 1px solid var(--border-medium, rgba(255, 255, 255, 0.15));
13
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
14
+ z-index: 1000;
15
+ display: flex;
16
+ flex-direction: column;
17
+ font-family: var(--font-primary);
18
+ animation: popupSlideIn 0.2s ease-out;
19
+ }
20
+
21
+ @keyframes popupSlideIn {
22
+ from {
23
+ opacity: 0;
24
+ transform: translateY(10px);
25
+ }
26
+ to {
27
+ opacity: 1;
28
+ transform: translateY(0);
29
+ }
30
+ }
31
+
32
+ /* Header */
33
+ .popup-header {
34
+ padding: 12px 12px 10px;
35
+ border-bottom: 1px solid var(--border-light);
36
+ }
37
+
38
+ .popup-title-row {
39
+ display: flex;
40
+ align-items: flex-start;
41
+ justify-content: space-between;
42
+ gap: 8px;
43
+ }
44
+
45
+ .popup-title-wrapper {
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 4px;
49
+ flex: 1;
50
+ min-width: 0;
51
+ }
52
+
53
+ .popup-title {
54
+ margin: 0;
55
+ font-size: 0.9rem;
56
+ font-weight: 600;
57
+ color: var(--text-primary);
58
+ line-height: 1.3;
59
+ word-break: break-word;
60
+ }
61
+
62
+ .popup-base-badge {
63
+ display: inline-flex;
64
+ align-items: center;
65
+ align-self: flex-start;
66
+ padding: 2px 6px;
67
+ font-size: 0.6rem;
68
+ font-weight: 600;
69
+ letter-spacing: 0.5px;
70
+ text-transform: uppercase;
71
+ color: #10b981;
72
+ background: rgba(16, 185, 129, 0.15);
73
+ border: 1px solid rgba(16, 185, 129, 0.3);
74
+ }
75
+
76
+ .popup-actions {
77
+ display: flex;
78
+ gap: 4px;
79
+ flex-shrink: 0;
80
+ }
81
+
82
+ /* Bookmark Button */
83
+ .popup-bookmark-btn {
84
+ background: transparent;
85
+ border: none;
86
+ color: var(--text-tertiary);
87
+ padding: 4px;
88
+ cursor: pointer;
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ transition: all var(--transition-base);
93
+ }
94
+
95
+ .popup-bookmark-btn:hover {
96
+ color: #f59e0b;
97
+ }
98
+
99
+ .popup-bookmark-btn.active {
100
+ color: #f59e0b;
101
+ }
102
+
103
+ .popup-close-btn {
104
+ background: transparent;
105
+ border: 1px solid var(--border-light);
106
+ color: var(--text-tertiary);
107
+ padding: 4px;
108
+ cursor: pointer;
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ transition: all var(--transition-base);
113
+ }
114
+
115
+ .popup-close-btn:hover {
116
+ color: #ef4444;
117
+ border-color: #ef4444;
118
+ background: rgba(239, 68, 68, 0.1);
119
+ }
120
+
121
+ /* Content */
122
+ .popup-content {
123
+ padding: 12px;
124
+ overflow-y: auto;
125
+ flex: 1;
126
+ }
127
+
128
+ /* Stats Row */
129
+ .popup-stats {
130
+ display: flex;
131
+ gap: 12px;
132
+ margin-bottom: 12px;
133
+ padding-bottom: 10px;
134
+ border-bottom: 1px solid var(--border-light);
135
+ }
136
+
137
+ .popup-stat {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 5px;
141
+ font-size: 0.8rem;
142
+ color: var(--text-primary);
143
+ }
144
+
145
+ .popup-stat svg {
146
+ color: var(--text-tertiary);
147
+ flex-shrink: 0;
148
+ }
149
+
150
+ .popup-stat.trending {
151
+ color: #10b981;
152
+ }
153
+
154
+ .popup-stat.trending svg {
155
+ color: #10b981;
156
+ }
157
+
158
+ /* Info Grid */
159
+ .popup-info-grid {
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: 8px;
163
+ margin-bottom: 12px;
164
+ }
165
+
166
+ .popup-info-item {
167
+ display: flex;
168
+ align-items: center;
169
+ gap: 6px;
170
+ font-size: 0.75rem;
171
+ }
172
+
173
+ .popup-info-item svg {
174
+ color: var(--text-tertiary);
175
+ flex-shrink: 0;
176
+ }
177
+
178
+ .popup-info-label {
179
+ color: var(--text-tertiary);
180
+ min-width: 50px;
181
+ }
182
+
183
+ .popup-info-value {
184
+ color: var(--text-primary);
185
+ font-weight: 500;
186
+ }
187
+
188
+ /* Lineage */
189
+ .popup-lineage {
190
+ margin-bottom: 12px;
191
+ padding: 8px;
192
+ background: var(--bg-secondary);
193
+ border: 1px solid var(--border-light);
194
+ }
195
+
196
+ .popup-lineage-label {
197
+ font-size: 0.65rem;
198
+ text-transform: uppercase;
199
+ letter-spacing: 0.5px;
200
+ color: var(--text-tertiary);
201
+ margin-bottom: 6px;
202
+ }
203
+
204
+ .popup-lineage-path {
205
+ display: flex;
206
+ flex-wrap: wrap;
207
+ align-items: center;
208
+ gap: 4px;
209
+ font-size: 0.75rem;
210
+ }
211
+
212
+ .popup-lineage-item {
213
+ color: var(--text-secondary);
214
+ padding: 2px 6px;
215
+ background: var(--bg-tertiary);
216
+ border: 1px solid var(--border-light);
217
+ max-width: 100px;
218
+ overflow: hidden;
219
+ text-overflow: ellipsis;
220
+ white-space: nowrap;
221
+ }
222
+
223
+ .popup-lineage-item.current {
224
+ color: var(--accent-blue);
225
+ border-color: var(--accent-blue);
226
+ background: rgba(74, 144, 226, 0.1);
227
+ }
228
+
229
+ .popup-lineage-separator {
230
+ color: var(--text-tertiary);
231
+ font-size: 0.7rem;
232
+ }
233
+
234
+ .popup-lineage-loading {
235
+ color: var(--text-tertiary);
236
+ font-style: italic;
237
+ font-size: 0.75rem;
238
+ }
239
+
240
+ .popup-lineage-base {
241
+ display: flex;
242
+ align-items: center;
243
+ gap: 5px;
244
+ color: #10b981;
245
+ font-weight: 500;
246
+ font-size: 0.75rem;
247
+ }
248
+
249
+ .popup-lineage-base svg {
250
+ color: #10b981;
251
+ }
252
+
253
+ /* Tags */
254
+ .popup-tags {
255
+ display: flex;
256
+ flex-wrap: wrap;
257
+ gap: 4px;
258
+ }
259
+
260
+ .popup-tag {
261
+ font-size: 0.65rem;
262
+ padding: 2px 6px;
263
+ background: var(--bg-tertiary);
264
+ border: 1px solid var(--border-light);
265
+ color: var(--text-secondary);
266
+ }
267
+
268
+ .popup-tag-more {
269
+ font-size: 0.65rem;
270
+ padding: 2px 6px;
271
+ color: var(--text-tertiary);
272
+ font-style: italic;
273
+ }
274
+
275
+ /* Footer */
276
+ .popup-footer {
277
+ padding: 10px 12px;
278
+ border-top: 1px solid var(--border-light);
279
+ background: var(--bg-secondary);
280
+ }
281
+
282
+ .popup-hf-link {
283
+ display: flex;
284
+ align-items: center;
285
+ justify-content: center;
286
+ gap: 6px;
287
+ padding: 8px 12px;
288
+ background: var(--accent-blue);
289
+ color: #ffffff;
290
+ text-decoration: none;
291
+ font-size: 0.8rem;
292
+ font-weight: 500;
293
+ transition: all var(--transition-base);
294
+ }
295
+
296
+ .popup-hf-link svg {
297
+ flex-shrink: 0;
298
+ }
299
+
300
+ .popup-hf-link:hover {
301
+ background: #3b82f6;
302
+ }
303
+
304
+ /* Light theme */
305
+ [data-theme="light"] .model-popup {
306
+ background: rgba(255, 255, 255, 0.98);
307
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
308
+ }
309
+
310
+ /* Responsive */
311
+ @media (max-width: 480px) {
312
+ .model-popup {
313
+ width: calc(100% - 40px);
314
+ max-width: 320px;
315
+ }
316
+ }
317
+
frontend/src/components/ui/ModelPopup.tsx ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Popup component for displaying model information in the bottom left.
3
+ * Replaces the full-screen modal with a compact, persistent popup.
4
+ */
5
+ import React, { useState, useEffect } from 'react';
6
+ import { X, ArrowUpRight, Bookmark, Download, Heart, TrendingUp, GitBranch, Tag, Layers, Box } from 'lucide-react';
7
+ import { ModelPoint } from '../../types';
8
+ import { getHuggingFaceUrl } from '../../utils/api/hfUrl';
9
+ import { API_BASE } from '../../config/api';
10
+ import './ModelPopup.css';
11
+
12
+ interface ModelPopupProps {
13
+ model: ModelPoint | null;
14
+ isOpen: boolean;
15
+ onClose: () => void;
16
+ onBookmark?: (modelId: string) => void;
17
+ isBookmarked?: boolean;
18
+ }
19
+
20
+ export default function ModelPopup({
21
+ model,
22
+ isOpen,
23
+ onClose,
24
+ onBookmark,
25
+ isBookmarked = false,
26
+ }: ModelPopupProps) {
27
+ const [lineagePath, setLineagePath] = useState<string[]>([]);
28
+ const [lineageLoading, setLineageLoading] = useState(false);
29
+
30
+ // Fetch lineage path when model changes
31
+ useEffect(() => {
32
+ if (!isOpen || !model) {
33
+ setLineagePath([]);
34
+ return;
35
+ }
36
+
37
+ const fetchLineage = async () => {
38
+ setLineageLoading(true);
39
+ try {
40
+ const response = await fetch(`${API_BASE}/api/family/path/${encodeURIComponent(model.model_id)}`);
41
+ if (response.ok) {
42
+ const data = await response.json();
43
+ setLineagePath(data.path || []);
44
+ } else {
45
+ setLineagePath([]);
46
+ }
47
+ } catch {
48
+ setLineagePath([]);
49
+ } finally {
50
+ setLineageLoading(false);
51
+ }
52
+ };
53
+
54
+ fetchLineage();
55
+ }, [model?.model_id, isOpen]);
56
+
57
+ // Handle escape key
58
+ useEffect(() => {
59
+ const handleKeyDown = (e: KeyboardEvent) => {
60
+ if (e.key === 'Escape' && isOpen) {
61
+ onClose();
62
+ }
63
+ };
64
+
65
+ document.addEventListener('keydown', handleKeyDown);
66
+ return () => document.removeEventListener('keydown', handleKeyDown);
67
+ }, [isOpen, onClose]);
68
+
69
+ if (!isOpen || !model) return null;
70
+
71
+ const formatNumber = (num: number | null): string => {
72
+ if (num === null || num === undefined) return '—';
73
+ if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
74
+ if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
75
+ return num.toLocaleString();
76
+ };
77
+
78
+ const formatDate = (dateString: string | null): string => {
79
+ if (!dateString) return '—';
80
+ try {
81
+ return new Date(dateString).toLocaleDateString('en-US', {
82
+ year: 'numeric',
83
+ month: 'short',
84
+ day: 'numeric'
85
+ });
86
+ } catch {
87
+ return dateString;
88
+ }
89
+ };
90
+
91
+ return (
92
+ <div className="model-popup">
93
+ {/* Header */}
94
+ <div className="popup-header">
95
+ <div className="popup-title-row">
96
+ <div className="popup-title-wrapper">
97
+ <h3 className="popup-title" title={model.model_id}>
98
+ {model.model_id}
99
+ </h3>
100
+ {!model.parent_model && (model.family_depth === 0 || model.family_depth === null) && (
101
+ <span className="popup-base-badge" title="This is a base model with no parent">BASE</span>
102
+ )}
103
+ </div>
104
+ <div className="popup-actions">
105
+ {onBookmark && (
106
+ <button
107
+ className={`popup-bookmark-btn ${isBookmarked ? 'active' : ''}`}
108
+ onClick={() => onBookmark(model.model_id)}
109
+ title={isBookmarked ? 'Remove bookmark' : 'Add bookmark'}
110
+ >
111
+ <Bookmark size={16} fill={isBookmarked ? 'currentColor' : 'none'} />
112
+ </button>
113
+ )}
114
+ <button className="popup-close-btn" onClick={onClose} title="Close">
115
+ <X size={16} />
116
+ </button>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ {/* Content */}
122
+ <div className="popup-content">
123
+ {/* Stats Row */}
124
+ <div className="popup-stats">
125
+ <div className="popup-stat" title={`Downloads: ${model.downloads?.toLocaleString() || 0} total downloads from Hugging Face`}>
126
+ <Download size={14} />
127
+ <span>{formatNumber(model.downloads)}</span>
128
+ </div>
129
+ <div className="popup-stat" title={`Likes: ${model.likes?.toLocaleString() || 0} community likes on Hugging Face`}>
130
+ <Heart size={14} />
131
+ <span>{formatNumber(model.likes)}</span>
132
+ </div>
133
+ {model.trending_score !== null && model.trending_score > 0 && (
134
+ <div className="popup-stat trending" title={`Trending Score: ${model.trending_score.toFixed(2)} — measures recent popularity growth`}>
135
+ <TrendingUp size={14} />
136
+ <span>{model.trending_score.toFixed(1)}</span>
137
+ </div>
138
+ )}
139
+ </div>
140
+
141
+ {/* Info Grid */}
142
+ <div className="popup-info-grid">
143
+ {model.library_name && (
144
+ <div className="popup-info-item" title={`ML Library: The framework used to build this model (e.g., transformers, diffusers, timm)`}>
145
+ <Layers size={14} />
146
+ <span className="popup-info-label">Library</span>
147
+ <span className="popup-info-value">{model.library_name}</span>
148
+ </div>
149
+ )}
150
+ {model.pipeline_tag && (
151
+ <div className="popup-info-item" title={`Task Type: What this model does (e.g., text-generation, image-classification)`}>
152
+ <Tag size={14} />
153
+ <span className="popup-info-label">Task</span>
154
+ <span className="popup-info-value">{model.pipeline_tag}</span>
155
+ </div>
156
+ )}
157
+ {model.created_at && (
158
+ <div className="popup-info-item" title="When this model was first published on Hugging Face">
159
+ <span className="popup-info-label">Created</span>
160
+ <span className="popup-info-value">{formatDate(model.created_at)}</span>
161
+ </div>
162
+ )}
163
+ {model.family_depth !== null && model.family_depth !== undefined && (
164
+ <div className="popup-info-item" title={`Family Depth: ${model.family_depth === 0 ? 'Base model (root of lineage tree)' : `${model.family_depth} generation${model.family_depth > 1 ? 's' : ''} from the root model`}`}>
165
+ <GitBranch size={14} />
166
+ <span className="popup-info-label">Depth</span>
167
+ <span className="popup-info-value">{model.family_depth}</span>
168
+ </div>
169
+ )}
170
+ </div>
171
+
172
+ {/* Lineage */}
173
+ <div className="popup-lineage" title="Model lineage shows the parent-child relationship from the original base model to this model">
174
+ <div className="popup-lineage-label">Lineage</div>
175
+ <div className="popup-lineage-path">
176
+ {lineageLoading ? (
177
+ <span className="popup-lineage-loading">Loading...</span>
178
+ ) : lineagePath.length > 1 ? (
179
+ lineagePath.map((pathModel, idx) => (
180
+ <React.Fragment key={pathModel}>
181
+ <span
182
+ className={`popup-lineage-item ${pathModel === model.model_id ? 'current' : ''}`}
183
+ title={pathModel}
184
+ >
185
+ {pathModel.split('/').pop()}
186
+ </span>
187
+ {idx < lineagePath.length - 1 && (
188
+ <span className="popup-lineage-separator">&gt;</span>
189
+ )}
190
+ </React.Fragment>
191
+ ))
192
+ ) : model.parent_model ? (
193
+ <>
194
+ <span className="popup-lineage-item" title={model.parent_model}>
195
+ {model.parent_model.split('/').pop()}
196
+ </span>
197
+ <span className="popup-lineage-separator">&gt;</span>
198
+ <span className="popup-lineage-item current" title={model.model_id}>
199
+ {model.model_id.split('/').pop()}
200
+ </span>
201
+ </>
202
+ ) : (
203
+ <span className="popup-lineage-base">
204
+ <Box size={14} />
205
+ Root model
206
+ </span>
207
+ )}
208
+ </div>
209
+ </div>
210
+
211
+ {/* Tags */}
212
+ {model.tags && (
213
+ <div className="popup-tags">
214
+ {(typeof model.tags === 'string' ? model.tags.split(',') : [])
215
+ .slice(0, 6)
216
+ .map((tag, idx) => (
217
+ <span key={idx} className="popup-tag">{tag.trim()}</span>
218
+ ))}
219
+ {(typeof model.tags === 'string' ? model.tags.split(',') : []).length > 6 && (
220
+ <span className="popup-tag-more">
221
+ +{(typeof model.tags === 'string' ? model.tags.split(',') : []).length - 6}
222
+ </span>
223
+ )}
224
+ </div>
225
+ )}
226
+ </div>
227
+
228
+ {/* Footer */}
229
+ <div className="popup-footer">
230
+ <a
231
+ href={getHuggingFaceUrl(model.model_id)}
232
+ target="_blank"
233
+ rel="noopener noreferrer"
234
+ className="popup-hf-link"
235
+ >
236
+ View on Hugging Face
237
+ <ArrowUpRight size={14} />
238
+ </a>
239
+ </div>
240
+ </div>
241
+ );
242
+ }
243
+
frontend/src/components/ui/ModelTooltip.css ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ MODEL TOOLTIP
3
+ ============================================ */
4
+
5
+ .model-tooltip {
6
+ position: fixed;
7
+ background: rgba(20, 20, 20, 0.98);
8
+ padding: 12px 16px;
9
+ font-size: 13px;
10
+ max-width: 350px;
11
+ z-index: 10000;
12
+ pointer-events: none;
13
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
14
+ border: 1px solid rgba(255, 255, 255, 0.1);
15
+ }
16
+
17
+ .model-tooltip-title {
18
+ font-weight: 600;
19
+ margin-bottom: 8px;
20
+ font-size: 14px;
21
+ color: #fff;
22
+ }
23
+
24
+ .model-tooltip-content {
25
+ margin-bottom: 6px;
26
+ font-size: 12px;
27
+ color: #d0d0d0;
28
+ }
29
+
30
+ .model-tooltip-row {
31
+ margin-bottom: 4px;
32
+ }
33
+
34
+ .model-tooltip-row-small {
35
+ margin-bottom: 4px;
36
+ font-size: 11px;
37
+ color: #aaa;
38
+ }
39
+
40
+ .model-tooltip-label {
41
+ color: #888;
42
+ }
43
+
44
+ .model-tooltip-label-spaced {
45
+ color: #888;
46
+ margin-left: 8px;
47
+ }
48
+
49
+ .model-tooltip-loading {
50
+ font-size: 11px;
51
+ color: #888;
52
+ font-style: italic;
53
+ margin-top: 8px;
54
+ }
55
+
56
+ .model-tooltip-description {
57
+ margin-top: 8px;
58
+ padding-top: 8px;
59
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
60
+ font-size: 12px;
61
+ color: #e0e0e0;
62
+ line-height: 1.4;
63
+ }
64
+
65
+ .model-tooltip-hint {
66
+ margin-top: 8px;
67
+ font-size: 11px;
68
+ color: #888;
69
+ font-style: italic;
70
+ }
71
+
frontend/src/components/ui/ModelTooltip.tsx CHANGED
@@ -4,6 +4,7 @@
4
  import React, { useEffect, useState } from 'react';
5
  import { ModelPoint } from '../../types';
6
  import { getHuggingFaceApiUrl } from '../../utils/api/hfUrl';
 
7
 
8
  interface ModelTooltipProps {
9
  model: ModelPoint | null;
@@ -21,7 +22,7 @@ function formatDate(dateString: string | null): string {
21
  if (!dateString) return '';
22
  try {
23
  const date = new Date(dateString);
24
- if (isNaN(date.getTime())) return dateString; // Return original if invalid
25
  return date.toLocaleDateString('en-US', {
26
  year: 'numeric',
27
  month: 'short',
@@ -42,19 +43,15 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP
42
  return;
43
  }
44
 
45
- // Check cache first
46
  if (cache.has(model.model_id)) {
47
  setDetails({ description: cache.get(model.model_id) });
48
  return;
49
  }
50
 
51
- // Fetch model description from Hugging Face API
52
  setDetails({ loading: true });
53
 
54
  const fetchDescription = async () => {
55
  try {
56
- // Try to get description from Hugging Face API
57
- // Use HF token if available (from env or localStorage)
58
  const hfToken = process.env.REACT_APP_HF_TOKEN ||
59
  (typeof window !== 'undefined' ? localStorage.getItem('HF_TOKEN') : null);
60
 
@@ -78,7 +75,6 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP
78
  null;
79
 
80
  if (description) {
81
- // Cache the description
82
  const newCache = new Map(cache);
83
  newCache.set(model.model_id, description);
84
  setCache(newCache);
@@ -89,8 +85,7 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP
89
  } else {
90
  setDetails({});
91
  }
92
- } catch (error) {
93
- console.error('Error fetching model description:', error);
94
  setDetails({});
95
  }
96
  };
@@ -109,81 +104,58 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP
109
 
110
  return (
111
  <div
 
112
  style={{
113
- position: 'fixed',
114
  left: `${position.x + 15}px`,
115
  top: `${position.y - 10}px`,
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,
123
- pointerEvents: 'none',
124
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
125
- border: '1px solid rgba(255, 255, 255, 0.1)',
126
  }}
127
  >
128
- <div style={{ fontWeight: '600', marginBottom: '8px', fontSize: '14px', color: '#fff' }}>
129
  {model.model_id}
130
  </div>
131
 
132
- <div style={{ marginBottom: '6px', fontSize: '12px', color: '#d0d0d0' }}>
133
  {model.library_name && (
134
- <div style={{ marginBottom: '4px' }}>
135
- <span style={{ color: '#888' }}>Library:</span> {model.library_name}
136
  </div>
137
  )}
138
  {model.pipeline_tag && (
139
- <div style={{ marginBottom: '4px' }}>
140
- <span style={{ color: '#888' }}>Task:</span> {model.pipeline_tag}
141
  </div>
142
  )}
143
- <div style={{ marginBottom: '4px' }}>
144
- <span style={{ color: '#888' }}>Downloads:</span> {model.downloads.toLocaleString()} |
145
- <span style={{ color: '#888', marginLeft: '8px' }}>Likes:</span> {model.likes.toLocaleString()}
146
  </div>
147
  {model.created_at && (
148
- <div style={{ marginBottom: '4px', fontSize: '11px', color: '#aaa' }}>
149
- <span style={{ color: '#888' }}>Created:</span> {formatDate(model.created_at)}
150
  </div>
151
  )}
152
  {model.parent_model && (
153
- <div style={{ marginBottom: '4px', fontSize: '11px', color: '#aaa' }}>
154
- <span style={{ color: '#888' }}>Parent:</span> {model.parent_model}
155
  </div>
156
  )}
157
  </div>
158
 
159
  {details.loading && (
160
- <div style={{ fontSize: '11px', color: '#888', fontStyle: 'italic', marginTop: '8px' }}>
161
  Loading description...
162
  </div>
163
  )}
164
 
165
  {truncatedDescription && (
166
- <div style={{
167
- marginTop: '8px',
168
- paddingTop: '8px',
169
- borderTop: '1px solid rgba(255, 255, 255, 0.1)',
170
- fontSize: '12px',
171
- color: '#e0e0e0',
172
- lineHeight: '1.4',
173
- }}>
174
  {truncatedDescription}
175
  </div>
176
  )}
177
 
178
- <div style={{
179
- marginTop: '8px',
180
- fontSize: '11px',
181
- color: '#888',
182
- fontStyle: 'italic'
183
- }}>
184
  Click for details
185
  </div>
186
  </div>
187
  );
188
  }
189
-
 
4
  import React, { useEffect, useState } from 'react';
5
  import { ModelPoint } from '../../types';
6
  import { getHuggingFaceApiUrl } from '../../utils/api/hfUrl';
7
+ import './ModelTooltip.css';
8
 
9
  interface ModelTooltipProps {
10
  model: ModelPoint | null;
 
22
  if (!dateString) return '';
23
  try {
24
  const date = new Date(dateString);
25
+ if (isNaN(date.getTime())) return dateString;
26
  return date.toLocaleDateString('en-US', {
27
  year: 'numeric',
28
  month: 'short',
 
43
  return;
44
  }
45
 
 
46
  if (cache.has(model.model_id)) {
47
  setDetails({ description: cache.get(model.model_id) });
48
  return;
49
  }
50
 
 
51
  setDetails({ loading: true });
52
 
53
  const fetchDescription = async () => {
54
  try {
 
 
55
  const hfToken = process.env.REACT_APP_HF_TOKEN ||
56
  (typeof window !== 'undefined' ? localStorage.getItem('HF_TOKEN') : null);
57
 
 
75
  null;
76
 
77
  if (description) {
 
78
  const newCache = new Map(cache);
79
  newCache.set(model.model_id, description);
80
  setCache(newCache);
 
85
  } else {
86
  setDetails({});
87
  }
88
+ } catch {
 
89
  setDetails({});
90
  }
91
  };
 
104
 
105
  return (
106
  <div
107
+ className="model-tooltip"
108
  style={{
 
109
  left: `${position.x + 15}px`,
110
  top: `${position.y - 10}px`,
 
 
 
 
 
 
 
 
 
 
111
  }}
112
  >
113
+ <div className="model-tooltip-title">
114
  {model.model_id}
115
  </div>
116
 
117
+ <div className="model-tooltip-content">
118
  {model.library_name && (
119
+ <div className="model-tooltip-row">
120
+ <span className="model-tooltip-label">Library:</span> {model.library_name}
121
  </div>
122
  )}
123
  {model.pipeline_tag && (
124
+ <div className="model-tooltip-row">
125
+ <span className="model-tooltip-label">Task:</span> {model.pipeline_tag}
126
  </div>
127
  )}
128
+ <div className="model-tooltip-row">
129
+ <span className="model-tooltip-label">Downloads:</span> {model.downloads.toLocaleString()} |
130
+ <span className="model-tooltip-label-spaced">Likes:</span> {model.likes.toLocaleString()}
131
  </div>
132
  {model.created_at && (
133
+ <div className="model-tooltip-row-small">
134
+ <span className="model-tooltip-label">Created:</span> {formatDate(model.created_at)}
135
  </div>
136
  )}
137
  {model.parent_model && (
138
+ <div className="model-tooltip-row-small">
139
+ <span className="model-tooltip-label">Parent:</span> {model.parent_model}
140
  </div>
141
  )}
142
  </div>
143
 
144
  {details.loading && (
145
+ <div className="model-tooltip-loading">
146
  Loading description...
147
  </div>
148
  )}
149
 
150
  {truncatedDescription && (
151
+ <div className="model-tooltip-description">
 
 
 
 
 
 
 
152
  {truncatedDescription}
153
  </div>
154
  )}
155
 
156
+ <div className="model-tooltip-hint">
 
 
 
 
 
157
  Click for details
158
  </div>
159
  </div>
160
  );
161
  }
 
frontend/src/components/ui/VirtualSearchResults.css ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ VIRTUAL SEARCH RESULTS
3
+ ============================================ */
4
+
5
+ .virtual-search-result {
6
+ cursor: pointer;
7
+ padding: 8px 12px;
8
+ background-color: transparent;
9
+ border-bottom: 1px solid var(--border-light);
10
+ transition: background-color var(--transition-base);
11
+ }
12
+
13
+ .virtual-search-result:hover {
14
+ background-color: var(--bg-secondary);
15
+ }
16
+
17
+ .virtual-search-result.selected {
18
+ background-color: var(--bg-tertiary);
19
+ }
20
+
21
+ .virtual-search-result-title {
22
+ font-weight: 500;
23
+ color: var(--text-primary);
24
+ }
25
+
26
+ .virtual-search-result-meta {
27
+ font-size: 0.875rem;
28
+ opacity: 0.7;
29
+ margin-top: 2px;
30
+ color: var(--text-secondary);
31
+ }
32
+
33
+ .virtual-search-result-stats {
34
+ font-size: 0.75rem;
35
+ opacity: 0.6;
36
+ margin-top: 2px;
37
+ color: var(--text-tertiary);
38
+ }
39
+
frontend/src/components/ui/VirtualSearchResults.tsx CHANGED
@@ -6,6 +6,7 @@ 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[];
@@ -24,33 +25,18 @@ export const VirtualSearchResults: React.FC<VirtualSearchResultsProps> = ({
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
  )}
 
6
  import { FixedSizeList as List } from 'react-window';
7
  import AutoSizer from 'react-virtualized-auto-sizer';
8
  import { SearchResult } from '../../types';
9
+ import './VirtualSearchResults.css';
10
 
11
  interface VirtualSearchResultsProps {
12
  results: SearchResult[];
 
25
 
26
  return (
27
  <div
28
+ style={style}
29
+ className={`virtual-search-result ${isSelected ? 'selected' : ''}`}
 
 
 
 
 
30
  onClick={() => onSelect(result)}
 
 
 
 
 
 
 
 
 
 
31
  >
32
+ <div className="virtual-search-result-title">{result.model_id}</div>
33
  {result.library_name && (
34
+ <div className="virtual-search-result-meta">
35
  {result.library_name} {result.pipeline_tag && `• ${result.pipeline_tag}`}
36
  </div>
37
  )}
38
  {(result.downloads || result.likes) && (
39
+ <div className="virtual-search-result-stats">
40
  {result.downloads?.toLocaleString()} downloads • {result.likes?.toLocaleString()} likes
41
  </div>
42
  )}
frontend/src/components/visualizations/AdoptionCurve.css ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .adoption-curve-container {
2
+ width: 100%;
3
+ display: flex;
4
+ justify-content: center;
5
+ align-items: center;
6
+ padding: 1rem;
7
+ position: relative;
8
+ background: var(--bg-primary, #ffffff);
9
+ border: 1px solid var(--border-light, #e0e0e0);
10
+ border-radius: 0;
11
+ }
12
+
13
+ .adoption-curve-empty {
14
+ padding: 3rem;
15
+ text-align: center;
16
+ color: var(--text-secondary, #666);
17
+ background: var(--bg-primary, #ffffff);
18
+ border: 1px solid var(--border-light, #e0e0e0);
19
+ border-radius: 0;
20
+ }
21
+
22
+ .adoption-tooltip {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: 0.5rem;
26
+ }
27
+
28
+ .tooltip-model-id {
29
+ font-weight: 600;
30
+ font-size: 0.9rem;
31
+ margin-bottom: 0.25rem;
32
+ color: var(--text-primary, #1a1a1a);
33
+ }
34
+
35
+ [data-theme="dark"] .tooltip-model-id {
36
+ color: #ffffff;
37
+ }
38
+
39
+ .tooltip-date {
40
+ color: var(--text-secondary, #666666);
41
+ font-size: 0.8rem;
42
+ }
43
+
44
+ [data-theme="dark"] .tooltip-date {
45
+ color: rgba(255, 255, 255, 0.8);
46
+ }
47
+
48
+ .tooltip-stats {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 0.25rem;
52
+ margin-top: 0.5rem;
53
+ padding-top: 0.5rem;
54
+ border-top: 1px solid var(--border-light, #e8e8e8);
55
+ font-size: 0.8rem;
56
+ }
57
+
58
+ [data-theme="dark"] .tooltip-stats {
59
+ border-top: 1px solid rgba(255, 255, 255, 0.2);
60
+ }
61
+
62
+ .tooltip-family {
63
+ font-weight: 600;
64
+ font-size: 0.9rem;
65
+ margin-bottom: 0.25rem;
66
+ color: var(--text-primary, #1a1a1a);
67
+ }
68
+
69
+ [data-theme="dark"] .tooltip-family {
70
+ color: #ffffff;
71
+ }
72
+
73
+ .adoption-legend {
74
+ position: absolute;
75
+ top: 20px;
76
+ right: 20px;
77
+ background: var(--bg-elevated, #ffffff);
78
+ border: 1px solid var(--border-medium, #d0d0d0);
79
+ border-radius: 8px;
80
+ padding: 1rem;
81
+ box-shadow: var(--shadow-lg, 0 2px 8px rgba(0, 0, 0, 0.12));
82
+ z-index: 10;
83
+ min-width: 180px;
84
+ backdrop-filter: blur(10px);
85
+ }
86
+
87
+ [data-theme="dark"] .adoption-legend {
88
+ background: rgba(20, 20, 20, 0.98);
89
+ border: 1px solid rgba(255, 255, 255, 0.25);
90
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
91
+ }
92
+
93
+ .legend-title {
94
+ font-size: 0.75rem;
95
+ font-weight: 600;
96
+ color: var(--text-secondary, #666666);
97
+ text-transform: uppercase;
98
+ letter-spacing: 0.05em;
99
+ margin-bottom: 0.75rem;
100
+ padding-bottom: 0.5rem;
101
+ border-bottom: 1px solid var(--border-light, #e8e8e8);
102
+ }
103
+
104
+ [data-theme="dark"] .legend-title {
105
+ border-bottom: 1px solid rgba(255, 255, 255, 0.25);
106
+ }
107
+
108
+ .legend-item {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 0.75rem;
112
+ margin-bottom: 0.625rem;
113
+ font-size: 0.875rem;
114
+ }
115
+
116
+ .legend-item:last-child {
117
+ margin-bottom: 0;
118
+ }
119
+
120
+ .legend-color-line {
121
+ width: 20px;
122
+ height: 3px;
123
+ border-radius: 2px;
124
+ flex-shrink: 0;
125
+ }
126
+
127
+ .legend-label {
128
+ color: var(--text-primary, #1a1a1a);
129
+ font-weight: 500;
130
+ flex: 1;
131
+ overflow: hidden;
132
+ text-overflow: ellipsis;
133
+ white-space: nowrap;
134
+ }
135
+
136
+ [data-theme="dark"] .legend-label {
137
+ color: #ffffff;
138
+ }
frontend/src/components/visualizations/AdoptionCurve.tsx ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo, useCallback } from 'react';
2
+ import { Group } from '@visx/group';
3
+ import { AreaClosed, LinePath } from '@visx/shape';
4
+ import { AxisLeft, AxisBottom } from '@visx/axis';
5
+ import { scaleTime, scaleLinear } from '@visx/scale';
6
+ import { useTooltip, TooltipWithBounds, defaultStyles } from '@visx/tooltip';
7
+ import { localPoint } from '@visx/event';
8
+ import { bisector } from 'd3-array';
9
+ import './AdoptionCurve.css';
10
+
11
+ export interface AdoptionDataPoint {
12
+ date: Date;
13
+ downloads: number;
14
+ modelId: string;
15
+ }
16
+
17
+ interface ProcessedAdoptionDataPoint extends AdoptionDataPoint {
18
+ cumulativeDownloads: number;
19
+ }
20
+
21
+ interface FamilyAdoptionData {
22
+ family: string;
23
+ data: AdoptionDataPoint[];
24
+ color?: string;
25
+ }
26
+
27
+ interface AdoptionCurveProps {
28
+ data: AdoptionDataPoint[];
29
+ selectedModel?: string;
30
+ width?: number;
31
+ height?: number;
32
+ margin?: { top: number; right: number; bottom: number; left: number };
33
+ // Comparison mode: multiple families
34
+ families?: FamilyAdoptionData[];
35
+ }
36
+
37
+ const defaultMargin = { top: 20, right: 20, bottom: 60, left: 80 };
38
+
39
+ const bisectDate = bisector<ProcessedAdoptionDataPoint, Date>((d) => d.date).left;
40
+
41
+ // Color palette for multiple families
42
+ const FAMILY_COLORS = [
43
+ '#3b82f6', // blue
44
+ '#ef4444', // red
45
+ '#10b981', // green
46
+ '#f59e0b', // amber
47
+ '#8b5cf6', // purple
48
+ '#ec4899', // pink
49
+ '#06b6d4', // cyan
50
+ '#f97316', // orange
51
+ ];
52
+
53
+ export default function AdoptionCurve({
54
+ data,
55
+ selectedModel,
56
+ width = 800,
57
+ height = 400,
58
+ margin = defaultMargin,
59
+ families,
60
+ }: AdoptionCurveProps) {
61
+ const {
62
+ tooltipData,
63
+ tooltipLeft,
64
+ tooltipTop,
65
+ tooltipOpen,
66
+ showTooltip,
67
+ hideTooltip,
68
+ } = useTooltip<ProcessedAdoptionDataPoint>();
69
+
70
+ // Process data: calculate cumulative downloads for single family mode
71
+ const processedData: ProcessedAdoptionDataPoint[] = useMemo(() => {
72
+ if (families && families.length > 0) return []; // Use families data instead
73
+
74
+ if (!data || data.length === 0) return [];
75
+
76
+ const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
77
+ let cumulative = 0;
78
+
79
+ return sorted.map((point) => {
80
+ cumulative += point.downloads;
81
+ return {
82
+ ...point,
83
+ cumulativeDownloads: cumulative,
84
+ };
85
+ });
86
+ }, [data, families]);
87
+
88
+ // Process multiple families for comparison mode
89
+ const processedFamilies = useMemo(() => {
90
+ if (!families || families.length === 0) return [];
91
+
92
+ return families.map((family, idx) => {
93
+ const sorted = [...family.data].sort((a, b) => a.date.getTime() - b.date.getTime());
94
+ let cumulative = 0;
95
+
96
+ const processed = sorted.map((point) => {
97
+ cumulative += point.downloads;
98
+ return {
99
+ ...point,
100
+ cumulativeDownloads: cumulative,
101
+ };
102
+ });
103
+
104
+ return {
105
+ family: family.family,
106
+ data: processed,
107
+ color: family.color || FAMILY_COLORS[idx % FAMILY_COLORS.length],
108
+ };
109
+ });
110
+ }, [families]);
111
+
112
+ // Calculate scales - handle both single and multi-family modes
113
+ const allDates = useMemo(() => {
114
+ if (processedFamilies.length > 0) {
115
+ return processedFamilies.flatMap(f => f.data.map(d => d.date));
116
+ }
117
+ return processedData.map(d => d.date);
118
+ }, [processedData, processedFamilies]);
119
+
120
+ const allMaxDownloads = useMemo(() => {
121
+ if (processedFamilies.length > 0) {
122
+ return Math.max(...processedFamilies.flatMap(f => f.data.map(d => d.cumulativeDownloads)));
123
+ }
124
+ return Math.max(...processedData.map(d => d.cumulativeDownloads));
125
+ }, [processedData, processedFamilies]);
126
+
127
+ const xScale = useMemo(() => {
128
+ if (allDates.length === 0) {
129
+ return scaleTime({
130
+ domain: [new Date(), new Date()],
131
+ range: [margin.left, width - margin.right],
132
+ });
133
+ }
134
+
135
+ return scaleTime({
136
+ domain: [new Date(Math.min(...allDates.map(d => d.getTime()))), new Date(Math.max(...allDates.map(d => d.getTime())))],
137
+ range: [margin.left, width - margin.right],
138
+ });
139
+ }, [allDates, width, margin]);
140
+
141
+ const yScale = useMemo(() => {
142
+ if (allDates.length === 0) {
143
+ return scaleLinear({
144
+ domain: [0, 1],
145
+ range: [height - margin.bottom, margin.top],
146
+ });
147
+ }
148
+
149
+ return scaleLinear({
150
+ domain: [0, allMaxDownloads * 1.1],
151
+ range: [height - margin.bottom, margin.top],
152
+ });
153
+ }, [allDates.length, allMaxDownloads, height, margin]);
154
+
155
+ const isComparisonMode = processedFamilies.length > 0;
156
+
157
+ // Handle mouse move for tooltip
158
+ const handleMouseMove = useCallback(
159
+ (event: React.MouseEvent<SVGRectElement>) => {
160
+ const coords = localPoint(event.currentTarget.ownerSVGElement!, event);
161
+ if (!coords) return;
162
+
163
+ const x0 = xScale.invert(coords.x - margin.left);
164
+ const date = x0 instanceof Date ? x0 : new Date(x0);
165
+
166
+ if (isComparisonMode) {
167
+ // Find closest point across all families
168
+ let closestPoint: ProcessedAdoptionDataPoint | null = null;
169
+ let closestFamily: string | null = null;
170
+ let minDistance = Infinity;
171
+
172
+ processedFamilies.forEach((family) => {
173
+ const index = bisectDate(family.data, date, 1);
174
+ const a = family.data[index - 1];
175
+ const b = family.data[index];
176
+
177
+ let point: ProcessedAdoptionDataPoint | null = null;
178
+ if (!b) {
179
+ point = a;
180
+ } else if (!a) {
181
+ point = b;
182
+ } else {
183
+ point = date.getTime() - a.date.getTime() > b.date.getTime() - date.getTime() ? b : a;
184
+ }
185
+
186
+ if (point) {
187
+ const distance = Math.abs(point.date.getTime() - date.getTime());
188
+ if (distance < minDistance) {
189
+ minDistance = distance;
190
+ closestPoint = point;
191
+ closestFamily = family.family;
192
+ }
193
+ }
194
+ });
195
+
196
+ if (closestPoint && closestFamily) {
197
+ const tooltipDataWithFamily = Object.assign({}, closestPoint, { family: closestFamily });
198
+ showTooltip({
199
+ tooltipData: tooltipDataWithFamily as any,
200
+ tooltipLeft: coords.x,
201
+ tooltipTop: coords.y,
202
+ });
203
+ }
204
+ } else {
205
+ const index = bisectDate(processedData, date, 1);
206
+ const a = processedData[index - 1];
207
+ const b = processedData[index];
208
+
209
+ let closestPoint: ProcessedAdoptionDataPoint | null = null;
210
+ if (!b) {
211
+ closestPoint = a;
212
+ } else if (!a) {
213
+ closestPoint = b;
214
+ } else {
215
+ closestPoint = date.getTime() - a.date.getTime() > b.date.getTime() - date.getTime() ? b : a;
216
+ }
217
+
218
+ if (closestPoint) {
219
+ showTooltip({
220
+ tooltipData: closestPoint,
221
+ tooltipLeft: coords.x,
222
+ tooltipTop: coords.y,
223
+ });
224
+ }
225
+ }
226
+ },
227
+ [processedData, processedFamilies, isComparisonMode, xScale, margin, showTooltip]
228
+ );
229
+
230
+ const hasData = isComparisonMode || processedData.length > 0;
231
+
232
+ if (!hasData) {
233
+ return (
234
+ <div className="adoption-curve-empty">
235
+ <p>No adoption data available</p>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ const innerWidth = width - margin.left - margin.right;
241
+ const innerHeight = height - margin.top - margin.bottom;
242
+
243
+ return (
244
+ <div className="adoption-curve-container">
245
+ {/* Legend for comparison mode - positioned nicely */}
246
+ {isComparisonMode && processedFamilies.length > 0 && (
247
+ <div className="adoption-legend">
248
+ <div className="legend-title">Families</div>
249
+ {processedFamilies.map((family) => (
250
+ <div key={family.family} className="legend-item">
251
+ <div
252
+ className="legend-color-line"
253
+ style={{ backgroundColor: family.color }}
254
+ />
255
+ <span className="legend-label">{family.family}</span>
256
+ </div>
257
+ ))}
258
+ </div>
259
+ )}
260
+ <svg width={width} height={height}>
261
+ <Group>
262
+ {isComparisonMode ? (
263
+ // Multi-family comparison mode
264
+ <>
265
+ {processedFamilies.map((family, familyIdx) => {
266
+ const color = family.color;
267
+ const rgbaColor = color + '33'; // Add alpha for area
268
+
269
+ return (
270
+ <React.Fragment key={family.family}>
271
+ {/* Area under curve */}
272
+ <AreaClosed<ProcessedAdoptionDataPoint>
273
+ data={family.data}
274
+ x={(d) => xScale(d.date) ?? 0}
275
+ y={(d) => yScale(d.cumulativeDownloads) ?? 0}
276
+ yScale={yScale}
277
+ fill={rgbaColor}
278
+ stroke="none"
279
+ />
280
+ {/* Line path */}
281
+ <LinePath<ProcessedAdoptionDataPoint>
282
+ data={family.data}
283
+ x={(d) => xScale(d.date) ?? 0}
284
+ y={(d) => yScale(d.cumulativeDownloads) ?? 0}
285
+ stroke={color}
286
+ strokeWidth={2.5}
287
+ strokeLinecap="round"
288
+ strokeLinejoin="round"
289
+ />
290
+ </React.Fragment>
291
+ );
292
+ })}
293
+ </>
294
+ ) : (
295
+ // Single family mode
296
+ <>
297
+ {/* Area under curve */}
298
+ <AreaClosed<ProcessedAdoptionDataPoint>
299
+ data={processedData}
300
+ x={(d) => xScale(d.date) ?? 0}
301
+ y={(d) => yScale(d.cumulativeDownloads) ?? 0}
302
+ yScale={yScale}
303
+ fill="rgba(59, 130, 246, 0.2)"
304
+ stroke="none"
305
+ />
306
+ {/* Line path */}
307
+ <LinePath<ProcessedAdoptionDataPoint>
308
+ data={processedData}
309
+ x={(d) => xScale(d.date) ?? 0}
310
+ y={(d) => yScale(d.cumulativeDownloads) ?? 0}
311
+ stroke="#3b82f6"
312
+ strokeWidth={2}
313
+ strokeLinecap="round"
314
+ strokeLinejoin="round"
315
+ />
316
+ {/* Data points */}
317
+ {processedData.map((point, idx) => {
318
+ const x = xScale(point.date) ?? 0;
319
+ const y = yScale(point.cumulativeDownloads) ?? 0;
320
+ const isSelected = selectedModel === point.modelId;
321
+
322
+ return (
323
+ <circle
324
+ key={`${point.modelId}-${idx}`}
325
+ cx={x}
326
+ cy={y}
327
+ r={isSelected ? 6 : 4}
328
+ fill={isSelected ? "#ef4444" : "#3b82f6"}
329
+ stroke={isSelected ? "#fff" : "none"}
330
+ strokeWidth={isSelected ? 2 : 0}
331
+ style={{ cursor: 'pointer' }}
332
+ />
333
+ );
334
+ })}
335
+ </>
336
+ )}
337
+
338
+ {/* X-axis */}
339
+ <AxisBottom
340
+ top={height - margin.bottom}
341
+ scale={xScale}
342
+ numTicks={width > 520 ? 8 : 4}
343
+ stroke="#666"
344
+ tickStroke="#666"
345
+ tickLabelProps={() => ({
346
+ fill: '#666',
347
+ fontSize: 10,
348
+ textAnchor: 'middle',
349
+ dy: -2,
350
+ })}
351
+ label="Date"
352
+ labelProps={{
353
+ fill: '#333',
354
+ fontSize: 11,
355
+ textAnchor: 'middle',
356
+ dy: 40,
357
+ }}
358
+ />
359
+
360
+ {/* Y-axis */}
361
+ <AxisLeft
362
+ left={margin.left}
363
+ scale={yScale}
364
+ numTicks={5}
365
+ tickFormat={(value) => {
366
+ const num = Number(value);
367
+ if (num >= 1000000) {
368
+ return `${(num / 1000000).toFixed(1)}M`;
369
+ } else if (num >= 1000) {
370
+ return `${(num / 1000).toFixed(0)}K`;
371
+ }
372
+ return num.toString();
373
+ }}
374
+ stroke="#666"
375
+ tickStroke="#666"
376
+ tickLabelProps={() => ({
377
+ fill: '#666',
378
+ fontSize: 10,
379
+ textAnchor: 'end',
380
+ dx: -4,
381
+ dy: 3,
382
+ })}
383
+ label="Cumulative Downloads"
384
+ labelProps={{
385
+ fill: '#333',
386
+ fontSize: 11,
387
+ textAnchor: 'middle',
388
+ transform: 'rotate(-90)',
389
+ dy: -50,
390
+ }}
391
+ />
392
+
393
+ {/* Invisible rect for mouse tracking */}
394
+ <rect
395
+ x={margin.left}
396
+ y={margin.top}
397
+ width={innerWidth}
398
+ height={innerHeight}
399
+ fill="transparent"
400
+ onMouseMove={handleMouseMove}
401
+ onMouseLeave={hideTooltip}
402
+ />
403
+ </Group>
404
+ </svg>
405
+
406
+ {/* Tooltip */}
407
+ {tooltipOpen && tooltipData && (
408
+ <TooltipWithBounds
409
+ top={tooltipTop}
410
+ left={tooltipLeft}
411
+ style={{
412
+ ...defaultStyles,
413
+ backgroundColor: 'var(--bg-elevated, #ffffff)',
414
+ color: 'var(--text-primary, #1a1a1a)',
415
+ border: '1px solid var(--border-medium, #d0d0d0)',
416
+ padding: '0.75rem',
417
+ borderRadius: '4px',
418
+ fontSize: '0.875rem',
419
+ pointerEvents: 'none',
420
+ boxShadow: 'var(--shadow-lg, 0 2px 8px rgba(0, 0, 0, 0.12))',
421
+ }}
422
+ >
423
+ <div className="adoption-tooltip">
424
+ {(tooltipData as any).family && (
425
+ <div className="tooltip-family" style={{ color: processedFamilies.find(f => f.family === (tooltipData as any).family)?.color }}>
426
+ {(tooltipData as any).family}
427
+ </div>
428
+ )}
429
+ <div className="tooltip-model-id">{tooltipData.modelId}</div>
430
+ <div className="tooltip-date">
431
+ {tooltipData.date.toLocaleDateString('en-US', {
432
+ year: 'numeric',
433
+ month: 'short',
434
+ day: 'numeric',
435
+ })}
436
+ </div>
437
+ <div className="tooltip-stats">
438
+ <div>Downloads: {tooltipData.downloads.toLocaleString()}</div>
439
+ <div>Cumulative: {tooltipData.cumulativeDownloads.toLocaleString()}</div>
440
+ </div>
441
+ </div>
442
+ </TooltipWithBounds>
443
+ )}
444
+
445
+ </div>
446
+ );
447
+ }
frontend/src/components/visualizations/DistanceHeatmap.css ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ DISTANCE HEATMAP
3
+ ============================================ */
4
+
5
+ .distance-heatmap-overlay {
6
+ position: absolute;
7
+ top: 0;
8
+ left: 0;
9
+ pointer-events: none;
10
+ z-index: 1;
11
+ }
12
+
13
+ .distance-heatmap-info {
14
+ position: absolute;
15
+ bottom: 10px;
16
+ left: 10px;
17
+ background: rgba(0, 0, 0, 0.7);
18
+ color: white;
19
+ padding: 8px 12px;
20
+ font-size: 11px;
21
+ font-family: var(--font-primary);
22
+ }
23
+
24
+ .distance-heatmap-title {
25
+ font-weight: 600;
26
+ margin-bottom: 4px;
27
+ }
28
+
29
+ .distance-heatmap-detail {
30
+ font-size: 10px;
31
+ opacity: 0.9;
32
+ }
33
+
34
+ .distance-heatmap-range {
35
+ font-size: 10px;
36
+ opacity: 0.8;
37
+ margin-top: 4px;
38
+ }
39
+
frontend/src/components/visualizations/DistanceHeatmap.tsx CHANGED
@@ -4,6 +4,7 @@
4
  */
5
  import React, { useMemo } from 'react';
6
  import { ModelPoint } from '../../types';
 
7
 
8
  interface DistanceHeatmapProps {
9
  data: ModelPoint[];
@@ -18,7 +19,6 @@ export default function DistanceHeatmap({
18
  selectedModel,
19
  width,
20
  height,
21
- opacity = 0.3
22
  }: DistanceHeatmapProps) {
23
  const distances = useMemo(() => {
24
  if (!selectedModel) return null;
@@ -45,39 +45,18 @@ export default function DistanceHeatmap({
45
 
46
  return (
47
  <div
48
- style={{
49
- position: 'absolute',
50
- top: 0,
51
- left: 0,
52
- width,
53
- height,
54
- pointerEvents: 'none',
55
- zIndex: 1
56
- }}
57
  >
58
- <div
59
- style={{
60
- position: 'absolute',
61
- bottom: 10,
62
- left: 10,
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>
72
- <div style={{ fontSize: '10px', opacity: 0.9 }}>
73
  Showing distance from: <strong>{selectedModel.model_id}</strong>
74
  </div>
75
- <div style={{ fontSize: '10px', opacity: 0.8, marginTop: '4px' }}>
76
  Range: {distances.minDist.toFixed(2)} - {distances.maxDist.toFixed(2)}
77
  </div>
78
  </div>
79
  </div>
80
  );
81
  }
82
-
83
-
 
4
  */
5
  import React, { useMemo } from 'react';
6
  import { ModelPoint } from '../../types';
7
+ import './DistanceHeatmap.css';
8
 
9
  interface DistanceHeatmapProps {
10
  data: ModelPoint[];
 
19
  selectedModel,
20
  width,
21
  height,
 
22
  }: DistanceHeatmapProps) {
23
  const distances = useMemo(() => {
24
  if (!selectedModel) return null;
 
45
 
46
  return (
47
  <div
48
+ className="distance-heatmap-overlay"
49
+ style={{ width, height }}
 
 
 
 
 
 
 
50
  >
51
+ <div className="distance-heatmap-info">
52
+ <div className="distance-heatmap-title">Distance Heatmap</div>
53
+ <div className="distance-heatmap-detail">
 
 
 
 
 
 
 
 
 
 
 
 
54
  Showing distance from: <strong>{selectedModel.model_id}</strong>
55
  </div>
56
+ <div className="distance-heatmap-range">
57
  Range: {distances.minDist.toFixed(2)} - {distances.maxDist.toFixed(2)}
58
  </div>
59
  </div>
60
  </div>
61
  );
62
  }
 
 
frontend/src/components/visualizations/MiniMap.css ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ MINI-MAP / OVERVIEW MAP
3
+ Best practices from GIS, embedding explorers, and visualization tools
4
+ ============================================ */
5
+
6
+ /* CSS Custom Properties for theming */
7
+ :root {
8
+ --minimap-bg: #0d1117;
9
+ --minimap-header: #161b22;
10
+ --minimap-footer: #161b22;
11
+ --minimap-border: #30363d;
12
+ --minimap-viewport-fill: rgba(59, 130, 246, 0.2);
13
+ --minimap-viewport-stroke: #3b82f6;
14
+ }
15
+
16
+ [data-theme="light"] {
17
+ --minimap-bg: #f6f8fa;
18
+ --minimap-header: #ffffff;
19
+ --minimap-footer: #ffffff;
20
+ --minimap-border: #d0d7de;
21
+ --minimap-viewport-fill: rgba(59, 130, 246, 0.15);
22
+ --minimap-viewport-stroke: #2563eb;
23
+ }
24
+
25
+ /* Container */
26
+ .minimap-container {
27
+ position: absolute;
28
+ bottom: 20px;
29
+ right: 20px;
30
+ background: var(--minimap-bg);
31
+ border: 1px solid var(--minimap-border);
32
+ box-shadow:
33
+ 0 8px 24px rgba(0, 0, 0, 0.4),
34
+ 0 2px 8px rgba(0, 0, 0, 0.2);
35
+ overflow: hidden;
36
+ z-index: 100;
37
+ user-select: none;
38
+ transition: box-shadow 0.2s ease, border-color 0.2s ease;
39
+ }
40
+
41
+ .minimap-container:hover {
42
+ box-shadow:
43
+ 0 12px 32px rgba(0, 0, 0, 0.5),
44
+ 0 4px 12px rgba(0, 0, 0, 0.25);
45
+ }
46
+
47
+ /* Minimize on hover out - subtle opacity */
48
+ .minimap-container.minimized {
49
+ opacity: 0.8;
50
+ }
51
+
52
+ /* Header */
53
+ .minimap-header {
54
+ display: flex;
55
+ justify-content: space-between;
56
+ align-items: center;
57
+ padding: 6px 10px;
58
+ background: var(--minimap-header);
59
+ border-bottom: 1px solid var(--minimap-border);
60
+ }
61
+
62
+ .minimap-title {
63
+ font-size: 10px;
64
+ font-weight: 600;
65
+ color: var(--text-secondary, #8b949e);
66
+ text-transform: uppercase;
67
+ letter-spacing: 0.8px;
68
+ font-family: var(--font-mono, 'SF Mono', Monaco, monospace);
69
+ }
70
+
71
+ .minimap-hint {
72
+ font-size: 9px;
73
+ color: var(--text-tertiary, #6e7681);
74
+ font-style: italic;
75
+ }
76
+
77
+ /* SVG Canvas */
78
+ .minimap-svg {
79
+ display: block;
80
+ cursor: crosshair;
81
+ background: var(--minimap-bg);
82
+ }
83
+
84
+ .minimap-svg:active {
85
+ cursor: grabbing;
86
+ }
87
+
88
+ /* Viewport rectangle styling (via D3, but CSS helps) */
89
+ .viewport-indicator {
90
+ pointer-events: all;
91
+ }
92
+
93
+ .viewport-fill {
94
+ transition: fill 0.15s ease;
95
+ }
96
+
97
+ .viewport-fill:hover {
98
+ fill: rgba(59, 130, 246, 0.3);
99
+ }
100
+
101
+ .viewport-border {
102
+ transition: stroke-width 0.15s ease;
103
+ }
104
+
105
+ .viewport-fill:hover + .viewport-border,
106
+ .viewport-border:hover {
107
+ stroke-width: 3px;
108
+ }
109
+
110
+ /* Stats footer */
111
+ .minimap-stats,
112
+ .minimap-footer {
113
+ display: none; /* Hidden by default */
114
+ }
115
+
116
+ .minimap-stat,
117
+ .minimap-label {
118
+ display: none;
119
+ }
120
+
121
+ /* Density cells animation */
122
+ .density-layer rect {
123
+ transition: opacity 0.2s ease;
124
+ }
125
+
126
+ /* Grid overlay */
127
+ .grid-overlay line {
128
+ pointer-events: none;
129
+ }
130
+
131
+ /* Light theme adjustments */
132
+ [data-theme="light"] .minimap-container {
133
+ box-shadow:
134
+ 0 4px 16px rgba(0, 0, 0, 0.12),
135
+ 0 1px 4px rgba(0, 0, 0, 0.08);
136
+ }
137
+
138
+ [data-theme="light"] .minimap-container:hover {
139
+ box-shadow:
140
+ 0 8px 24px rgba(0, 0, 0, 0.16),
141
+ 0 2px 8px rgba(0, 0, 0, 0.1);
142
+ }
143
+
144
+ [data-theme="light"] .minimap-title {
145
+ color: var(--text-primary, #24292f);
146
+ }
147
+
148
+ /* Responsive: hide on small screens */
149
+ @media (max-width: 768px) {
150
+ .minimap-container {
151
+ display: none;
152
+ }
153
+ }
154
+
155
+ /* Compact variant for smaller screens */
156
+ @media (max-width: 1200px) and (min-width: 769px) {
157
+ .minimap-container {
158
+ bottom: 12px;
159
+ right: 12px;
160
+ }
161
+
162
+ .minimap-header {
163
+ padding: 4px 8px;
164
+ }
165
+
166
+ .minimap-title {
167
+ font-size: 9px;
168
+ }
169
+ }
170
+
171
+ /* Animation for viewport pulsing (subtle attention) */
172
+ @keyframes viewport-pulse {
173
+ 0%, 100% {
174
+ stroke-opacity: 1;
175
+ }
176
+ 50% {
177
+ stroke-opacity: 0.7;
178
+ }
179
+ }
180
+
181
+ /* Only pulse when first appearing or after navigation */
182
+ .minimap-svg .viewport-border.pulse {
183
+ animation: viewport-pulse 1.5s ease-in-out 2;
184
+ }
185
+
186
+ /* Drag state */
187
+ .minimap-container.dragging .minimap-svg {
188
+ cursor: grabbing;
189
+ }
190
+
191
+ .minimap-container.dragging .viewport-fill,
192
+ .minimap-container.dragging .viewport-border {
193
+ stroke: #60a5fa;
194
+ fill: rgba(96, 165, 250, 0.25);
195
+ }
196
+
197
+ /* Focus state for accessibility */
198
+ .minimap-svg:focus {
199
+ outline: 2px solid var(--minimap-viewport-stroke);
200
+ outline-offset: 2px;
201
+ }
202
+
203
+ .minimap-svg:focus:not(:focus-visible) {
204
+ outline: none;
205
+ }
206
+
207
+ /* Tooltip for interaction hints */
208
+ .minimap-container[data-tooltip]::after {
209
+ content: attr(data-tooltip);
210
+ position: absolute;
211
+ bottom: 100%;
212
+ left: 50%;
213
+ transform: translateX(-50%);
214
+ padding: 4px 8px;
215
+ background: var(--bg-elevated, #1c2128);
216
+ color: var(--text-primary, #e6edf3);
217
+ font-size: 11px;
218
+ white-space: nowrap;
219
+ opacity: 0;
220
+ pointer-events: none;
221
+ transition: opacity 0.15s ease;
222
+ margin-bottom: 8px;
223
+ }
224
+
225
+ .minimap-container:hover[data-tooltip]::after {
226
+ opacity: 1;
227
+ }
frontend/src/components/visualizations/MiniMap.tsx ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect, useMemo, useCallback } from 'react';
2
+ import * as d3 from 'd3';
3
+ import { ModelPoint } from '../../types';
4
+
5
+ interface MiniMapProps {
6
+ width: number;
7
+ height: number;
8
+ data: ModelPoint[];
9
+ colorBy: string;
10
+ // Main plot dimensions
11
+ mainWidth: number;
12
+ mainHeight: number;
13
+ mainMargin: { top: number; right: number; bottom: number; left: number };
14
+ // Current transform from main plot
15
+ transform: d3.ZoomTransform;
16
+ // Callback to update main plot transform
17
+ onViewportChange?: (transform: d3.ZoomTransform) => void;
18
+ // Base scales from main plot
19
+ xScaleBase: d3.ScaleLinear<number, number>;
20
+ yScaleBase: d3.ScaleLinear<number, number>;
21
+ }
22
+
23
+ export default function MiniMap({
24
+ width,
25
+ height,
26
+ data,
27
+ colorBy,
28
+ mainWidth,
29
+ mainHeight,
30
+ mainMargin,
31
+ transform,
32
+ onViewportChange,
33
+ xScaleBase,
34
+ yScaleBase,
35
+ }: MiniMapProps) {
36
+ const svgRef = useRef<SVGSVGElement>(null);
37
+ const isDragging = useRef(false);
38
+
39
+ // Sample data for mini-map (show fewer points for performance)
40
+ const sampledData = useMemo(() => {
41
+ const maxPoints = 2000;
42
+ if (data.length <= maxPoints) return data;
43
+
44
+ const step = Math.ceil(data.length / maxPoints);
45
+ return data.filter((_, i) => i % step === 0);
46
+ }, [data]);
47
+
48
+ // Mini-map scales (fit entire data in mini-map viewport)
49
+ const { miniXScale, miniYScale, colorScale } = useMemo(() => {
50
+ if (data.length === 0) {
51
+ return {
52
+ miniXScale: d3.scaleLinear(),
53
+ miniYScale: d3.scaleLinear(),
54
+ colorScale: () => '#666',
55
+ };
56
+ }
57
+
58
+ const padding = 8;
59
+ const xExtent = d3.extent(data, (d) => d.x) as [number, number];
60
+ const yExtent = d3.extent(data, (d) => d.y) as [number, number];
61
+
62
+ const miniXScale = d3
63
+ .scaleLinear()
64
+ .domain(xExtent)
65
+ .range([padding, width - padding]);
66
+
67
+ const miniYScale = d3
68
+ .scaleLinear()
69
+ .domain(yExtent)
70
+ .range([height - padding, padding]);
71
+
72
+ // Color scale
73
+ let colorScale: (d: ModelPoint) => string;
74
+
75
+ if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
76
+ const categories = Array.from(new Set(data.map((d) =>
77
+ colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown')
78
+ )));
79
+ const ordinalScale = d3.scaleOrdinal(d3.schemeTableau10).domain(categories);
80
+ colorScale = (d: ModelPoint) => {
81
+ const value = colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown');
82
+ return ordinalScale(value);
83
+ };
84
+ } else {
85
+ const values = data.map((d) => colorBy === 'downloads' ? d.downloads : d.likes);
86
+ const extent = d3.extent(values) as [number, number];
87
+ const logExtent: [number, number] = [Math.log10(extent[0] + 1), Math.log10(extent[1] + 1)];
88
+ const sequentialScale = d3.scaleSequential(d3.interpolateTurbo).domain(logExtent);
89
+
90
+ colorScale = (d: ModelPoint) => {
91
+ const val = colorBy === 'downloads' ? d.downloads : d.likes;
92
+ return sequentialScale(Math.log10(val + 1));
93
+ };
94
+ }
95
+
96
+ return { miniXScale, miniYScale, colorScale };
97
+ }, [data, width, height, colorBy]);
98
+
99
+ // Calculate viewport rectangle in mini-map coordinates
100
+ const viewportRect = useMemo(() => {
101
+ if (!xScaleBase.domain || !yScaleBase.domain) return null;
102
+
103
+ const mainPlotWidth = mainWidth - mainMargin.left - mainMargin.right;
104
+ const mainPlotHeight = mainHeight - mainMargin.top - mainMargin.bottom;
105
+
106
+ // Get the visible domain in data coordinates
107
+ const visibleXDomain = transform.rescaleX(xScaleBase).domain();
108
+ const visibleYDomain = transform.rescaleY(yScaleBase).domain();
109
+
110
+ // Convert to mini-map coordinates
111
+ const x = miniXScale(visibleXDomain[0]);
112
+ const y = miniYScale(visibleYDomain[1]); // Note: y is inverted
113
+ const rectWidth = miniXScale(visibleXDomain[1]) - miniXScale(visibleXDomain[0]);
114
+ const rectHeight = miniYScale(visibleYDomain[0]) - miniYScale(visibleYDomain[1]);
115
+
116
+ return {
117
+ x: Math.max(0, x),
118
+ y: Math.max(0, y),
119
+ width: Math.min(width, rectWidth),
120
+ height: Math.min(height, rectHeight),
121
+ };
122
+ }, [transform, xScaleBase, yScaleBase, miniXScale, miniYScale, mainWidth, mainHeight, mainMargin, width, height]);
123
+
124
+ // Handle click on mini-map to pan
125
+ const handleClick = useCallback((event: React.MouseEvent<SVGSVGElement>) => {
126
+ if (!onViewportChange || !svgRef.current) return;
127
+
128
+ const rect = svgRef.current.getBoundingClientRect();
129
+ const clickX = event.clientX - rect.left;
130
+ const clickY = event.clientY - rect.top;
131
+
132
+ // Convert click position to data coordinates
133
+ const dataX = miniXScale.invert(clickX);
134
+ const dataY = miniYScale.invert(clickY);
135
+
136
+ // Calculate new transform to center on clicked point
137
+ const mainPlotWidth = mainWidth - mainMargin.left - mainMargin.right;
138
+ const mainPlotHeight = mainHeight - mainMargin.top - mainMargin.bottom;
139
+
140
+ // Get center of current viewport in data coordinates
141
+ const newCenterX = xScaleBase(dataX);
142
+ const newCenterY = yScaleBase(dataY);
143
+
144
+ // Calculate translation to center on this point
145
+ const newX = mainPlotWidth / 2 - transform.k * newCenterX;
146
+ const newY = mainPlotHeight / 2 - transform.k * newCenterY;
147
+
148
+ const newTransform = d3.zoomIdentity
149
+ .translate(newX, newY)
150
+ .scale(transform.k);
151
+
152
+ onViewportChange(newTransform);
153
+ }, [onViewportChange, miniXScale, miniYScale, xScaleBase, yScaleBase, mainWidth, mainHeight, mainMargin, transform.k]);
154
+
155
+ // Handle drag on viewport
156
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
157
+ event.stopPropagation();
158
+ isDragging.current = true;
159
+ document.body.style.cursor = 'grabbing';
160
+ }, []);
161
+
162
+ const handleMouseMove = useCallback((event: MouseEvent) => {
163
+ if (!isDragging.current || !onViewportChange || !svgRef.current) return;
164
+
165
+ const rect = svgRef.current.getBoundingClientRect();
166
+ const mouseX = event.clientX - rect.left;
167
+ const mouseY = event.clientY - rect.top;
168
+
169
+ // Convert mouse position to data coordinates
170
+ const dataX = miniXScale.invert(mouseX);
171
+ const dataY = miniYScale.invert(mouseY);
172
+
173
+ // Calculate new transform
174
+ const mainPlotWidth = mainWidth - mainMargin.left - mainMargin.right;
175
+ const mainPlotHeight = mainHeight - mainMargin.top - mainMargin.bottom;
176
+
177
+ const newCenterX = xScaleBase(dataX);
178
+ const newCenterY = yScaleBase(dataY);
179
+
180
+ const newX = mainPlotWidth / 2 - transform.k * newCenterX;
181
+ const newY = mainPlotHeight / 2 - transform.k * newCenterY;
182
+
183
+ const newTransform = d3.zoomIdentity
184
+ .translate(newX, newY)
185
+ .scale(transform.k);
186
+
187
+ onViewportChange(newTransform);
188
+ }, [onViewportChange, miniXScale, miniYScale, xScaleBase, yScaleBase, mainWidth, mainHeight, mainMargin, transform.k]);
189
+
190
+ const handleMouseUp = useCallback(() => {
191
+ isDragging.current = false;
192
+ document.body.style.cursor = '';
193
+ }, []);
194
+
195
+ // Add global mouse event listeners for dragging
196
+ useEffect(() => {
197
+ document.addEventListener('mousemove', handleMouseMove);
198
+ document.addEventListener('mouseup', handleMouseUp);
199
+
200
+ return () => {
201
+ document.removeEventListener('mousemove', handleMouseMove);
202
+ document.removeEventListener('mouseup', handleMouseUp);
203
+ };
204
+ }, [handleMouseMove, handleMouseUp]);
205
+
206
+ // Render mini-map
207
+ useEffect(() => {
208
+ if (!svgRef.current || sampledData.length === 0) return;
209
+
210
+ const svg = d3.select(svgRef.current);
211
+
212
+ // Only update points layer, preserve viewport rect
213
+ let pointsGroup = svg.select<SVGGElement>('.minimap-points');
214
+ if (pointsGroup.empty()) {
215
+ svg.selectAll('*').remove();
216
+
217
+ // Background
218
+ svg.append('rect')
219
+ .attr('class', 'minimap-bg')
220
+ .attr('width', width)
221
+ .attr('height', height)
222
+ .attr('fill', 'var(--bg-secondary, #1a1a1a)')
223
+ .attr('rx', 4);
224
+
225
+ // Points group
226
+ pointsGroup = svg.append('g').attr('class', 'minimap-points');
227
+ }
228
+
229
+ // Draw points
230
+ pointsGroup
231
+ .selectAll<SVGCircleElement, ModelPoint>('circle')
232
+ .data(sampledData, (d) => d.model_id)
233
+ .join(
234
+ (enter) => enter
235
+ .append('circle')
236
+ .attr('cx', (d) => miniXScale(d.x))
237
+ .attr('cy', (d) => miniYScale(d.y))
238
+ .attr('r', 1.5)
239
+ .attr('fill', (d) => colorScale(d))
240
+ .attr('opacity', 0.6),
241
+ (update) => update
242
+ .attr('cx', (d) => miniXScale(d.x))
243
+ .attr('cy', (d) => miniYScale(d.y))
244
+ .attr('fill', (d) => colorScale(d)),
245
+ (exit) => exit.remove()
246
+ );
247
+
248
+ }, [sampledData, width, height, miniXScale, miniYScale, colorScale]);
249
+
250
+ // Update viewport rectangle separately for performance
251
+ useEffect(() => {
252
+ if (!svgRef.current || !viewportRect) return;
253
+
254
+ const svg = d3.select(svgRef.current);
255
+
256
+ // Remove old viewport
257
+ svg.selectAll('.viewport-rect, .viewport-border').remove();
258
+
259
+ // Viewport fill
260
+ svg.append('rect')
261
+ .attr('class', 'viewport-rect')
262
+ .attr('x', viewportRect.x)
263
+ .attr('y', viewportRect.y)
264
+ .attr('width', Math.max(viewportRect.width, 10))
265
+ .attr('height', Math.max(viewportRect.height, 10))
266
+ .attr('fill', 'rgba(74, 144, 226, 0.15)')
267
+ .attr('stroke', 'rgba(74, 144, 226, 0.8)')
268
+ .attr('stroke-width', 2)
269
+ .attr('rx', 2)
270
+ .style('cursor', 'grab')
271
+ .style('pointer-events', 'all');
272
+
273
+ // Corner handles for visual feedback
274
+ const handleSize = 6;
275
+ const corners = [
276
+ { x: viewportRect.x, y: viewportRect.y },
277
+ { x: viewportRect.x + viewportRect.width, y: viewportRect.y },
278
+ { x: viewportRect.x, y: viewportRect.y + viewportRect.height },
279
+ { x: viewportRect.x + viewportRect.width, y: viewportRect.y + viewportRect.height },
280
+ ];
281
+
282
+ svg.selectAll('.viewport-handle')
283
+ .data(corners)
284
+ .join('rect')
285
+ .attr('class', 'viewport-handle')
286
+ .attr('x', (d) => d.x - handleSize / 2)
287
+ .attr('y', (d) => d.y - handleSize / 2)
288
+ .attr('width', handleSize)
289
+ .attr('height', handleSize)
290
+ .attr('fill', 'rgba(74, 144, 226, 1)')
291
+ .attr('rx', 1);
292
+
293
+ }, [viewportRect]);
294
+
295
+ if (data.length === 0) return null;
296
+
297
+ return (
298
+ <div className="minimap-container">
299
+ <div className="minimap-header">
300
+ <span className="minimap-title">Overview Map</span>
301
+ <span className="minimap-hint">Click to navigate</span>
302
+ </div>
303
+ <svg
304
+ ref={svgRef}
305
+ width={width}
306
+ height={height}
307
+ className="minimap-svg"
308
+ onClick={handleClick}
309
+ onMouseDown={handleMouseDown}
310
+ />
311
+ <div className="minimap-stats">
312
+ <span>Zoom: {transform.k.toFixed(1)}x</span>
313
+ </div>
314
+ </div>
315
+ );
316
+ }
317
+
frontend/src/components/visualizations/MiniMap3D.tsx ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo } from 'react';
2
+ import { Canvas, useThree, useFrame } from '@react-three/fiber';
3
+ import * as THREE from 'three';
4
+ import { ModelPoint } from '../../types';
5
+ import { getCategoricalColorMap, getContinuousColorScale, getDepthColorScale } from '../../utils/rendering/colors';
6
+
7
+ interface MiniMap3DProps {
8
+ width?: number;
9
+ height?: number;
10
+ data: ModelPoint[];
11
+ colorBy: string;
12
+ cameraPosition: [number, number, number];
13
+ cameraTarget: [number, number, number];
14
+ onNavigate?: (position: [number, number, number], target: [number, number, number]) => void;
15
+ }
16
+
17
+ // Mini-map point cloud component
18
+ function MiniMapPoints({
19
+ data,
20
+ colorBy
21
+ }: {
22
+ data: ModelPoint[];
23
+ colorBy: string;
24
+ }) {
25
+ const { positions, colors } = useMemo(() => {
26
+ // Sample for performance (max 1000 points for mini-map)
27
+ const MAX_POINTS = 1000;
28
+ const step = Math.ceil(data.length / MAX_POINTS);
29
+ const sampled: ModelPoint[] = [];
30
+
31
+ for (let i = 0; i < data.length && sampled.length < MAX_POINTS; i += step) {
32
+ sampled.push(data[i]);
33
+ }
34
+
35
+ const count = sampled.length;
36
+ const positions = new Float32Array(count * 3);
37
+ const colors = new Float32Array(count * 3);
38
+
39
+ // Calculate color scale
40
+ let colorScale: any;
41
+
42
+ if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
43
+ const categories = Array.from(new Set(sampled.map((d) =>
44
+ colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown')
45
+ )));
46
+ const colorSchemeType = colorBy === 'library_name' ? 'library' : 'pipeline';
47
+ colorScale = getCategoricalColorMap(categories, colorSchemeType);
48
+ } else if (colorBy === 'downloads' || colorBy === 'likes') {
49
+ const values = sampled.map((d) => colorBy === 'downloads' ? d.downloads : d.likes);
50
+ if (values.length > 0) {
51
+ const min = Math.min(...values);
52
+ const max = Math.max(...values);
53
+ colorScale = getContinuousColorScale(min, max, 'viridis', true);
54
+ }
55
+ } else if (colorBy === 'family_depth') {
56
+ const depths = sampled.map((d) => d.family_depth ?? 0);
57
+ if (depths.length > 0) {
58
+ const maxDepth = Math.max(...depths, 1);
59
+ colorScale = getDepthColorScale(maxDepth, true);
60
+ }
61
+ }
62
+
63
+ sampled.forEach((model, idx) => {
64
+ positions[idx * 3] = model.x;
65
+ positions[idx * 3 + 1] = model.y;
66
+ positions[idx * 3 + 2] = model.z;
67
+
68
+ let colorHex = '#60a5fa';
69
+
70
+ if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
71
+ const value = colorBy === 'library_name'
72
+ ? (model.library_name || 'unknown')
73
+ : (model.pipeline_tag || 'unknown');
74
+ colorHex = colorScale instanceof Map ? colorScale.get(value) || '#60a5fa' : '#60a5fa';
75
+ } else if (colorBy === 'downloads' || colorBy === 'likes') {
76
+ const val = colorBy === 'downloads' ? model.downloads : model.likes;
77
+ colorHex = typeof colorScale === 'function' ? colorScale(val) : '#60a5fa';
78
+ } else if (colorBy === 'family_depth') {
79
+ if (typeof colorScale === 'function') {
80
+ colorHex = colorScale(model.family_depth ?? 0);
81
+ }
82
+ }
83
+
84
+ const color = new THREE.Color(colorHex);
85
+ colors[idx * 3] = color.r;
86
+ colors[idx * 3 + 1] = color.g;
87
+ colors[idx * 3 + 2] = color.b;
88
+ });
89
+
90
+ return { positions, colors };
91
+ }, [data, colorBy]);
92
+
93
+ return (
94
+ <points>
95
+ <bufferGeometry>
96
+ <bufferAttribute
97
+ attach="attributes-position"
98
+ count={positions.length / 3}
99
+ array={positions}
100
+ itemSize={3}
101
+ />
102
+ <bufferAttribute
103
+ attach="attributes-color"
104
+ count={colors.length / 3}
105
+ array={colors}
106
+ itemSize={3}
107
+ />
108
+ </bufferGeometry>
109
+ <pointsMaterial
110
+ size={0.2}
111
+ vertexColors
112
+ transparent
113
+ opacity={0.7}
114
+ sizeAttenuation={false}
115
+ />
116
+ </points>
117
+ );
118
+ }
119
+
120
+ // Camera indicator showing viewing direction
121
+ function CameraIndicator({
122
+ position,
123
+ target
124
+ }: {
125
+ position: [number, number, number];
126
+ target: [number, number, number];
127
+ }) {
128
+ // Calculate look direction
129
+ const lookDir = useMemo(() => {
130
+ const dir = new THREE.Vector3(
131
+ target[0] - position[0],
132
+ target[1] - position[1],
133
+ target[2] - position[2]
134
+ ).normalize();
135
+ return dir;
136
+ }, [position, target]);
137
+
138
+ // Create frustum vertices
139
+ const frustumGeometry = useMemo(() => {
140
+ const geometry = new THREE.BufferGeometry();
141
+
142
+ const apex = new THREE.Vector3(...position);
143
+ const forward = lookDir.clone().multiplyScalar(3);
144
+ const right = new THREE.Vector3().crossVectors(lookDir, new THREE.Vector3(0, 1, 0)).normalize();
145
+ const up = new THREE.Vector3().crossVectors(right, lookDir).normalize();
146
+
147
+ const baseCenter = apex.clone().add(forward);
148
+ const halfWidth = 1.5;
149
+ const halfHeight = 1;
150
+
151
+ const corners = [
152
+ baseCenter.clone().add(right.clone().multiplyScalar(halfWidth)).add(up.clone().multiplyScalar(halfHeight)),
153
+ baseCenter.clone().add(right.clone().multiplyScalar(-halfWidth)).add(up.clone().multiplyScalar(halfHeight)),
154
+ baseCenter.clone().add(right.clone().multiplyScalar(-halfWidth)).add(up.clone().multiplyScalar(-halfHeight)),
155
+ baseCenter.clone().add(right.clone().multiplyScalar(halfWidth)).add(up.clone().multiplyScalar(-halfHeight)),
156
+ ];
157
+
158
+ const vertices = new Float32Array([
159
+ apex.x, apex.y, apex.z, corners[0].x, corners[0].y, corners[0].z,
160
+ apex.x, apex.y, apex.z, corners[1].x, corners[1].y, corners[1].z,
161
+ apex.x, apex.y, apex.z, corners[2].x, corners[2].y, corners[2].z,
162
+ apex.x, apex.y, apex.z, corners[3].x, corners[3].y, corners[3].z,
163
+ corners[0].x, corners[0].y, corners[0].z, corners[1].x, corners[1].y, corners[1].z,
164
+ corners[1].x, corners[1].y, corners[1].z, corners[2].x, corners[2].y, corners[2].z,
165
+ corners[2].x, corners[2].y, corners[2].z, corners[3].x, corners[3].y, corners[3].z,
166
+ corners[3].x, corners[3].y, corners[3].z, corners[0].x, corners[0].y, corners[0].z,
167
+ ]);
168
+
169
+ geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
170
+ return geometry;
171
+ }, [position, lookDir]);
172
+
173
+ return (
174
+ <group>
175
+ {/* Camera position sphere */}
176
+ <mesh position={position}>
177
+ <sphereGeometry args={[0.4, 16, 16]} />
178
+ <meshBasicMaterial color="#f97316" />
179
+ </mesh>
180
+
181
+ {/* Target indicator */}
182
+ <mesh position={target}>
183
+ <sphereGeometry args={[0.2, 8, 8]} />
184
+ <meshBasicMaterial color="#22d3ee" />
185
+ </mesh>
186
+
187
+ {/* Line from camera to target */}
188
+ <line>
189
+ <bufferGeometry>
190
+ <bufferAttribute
191
+ attach="attributes-position"
192
+ count={2}
193
+ array={new Float32Array([...position, ...target])}
194
+ itemSize={3}
195
+ />
196
+ </bufferGeometry>
197
+ <lineBasicMaterial color="#f97316" opacity={0.7} transparent />
198
+ </line>
199
+
200
+ {/* Viewing frustum */}
201
+ <lineSegments geometry={frustumGeometry}>
202
+ <lineBasicMaterial color="#f97316" opacity={0.5} transparent />
203
+ </lineSegments>
204
+ </group>
205
+ );
206
+ }
207
+
208
+ // Axis helper for orientation
209
+ function AxisHelper() {
210
+ return (
211
+ <group>
212
+ {/* X axis - red */}
213
+ <line>
214
+ <bufferGeometry>
215
+ <bufferAttribute
216
+ attach="attributes-position"
217
+ count={2}
218
+ array={new Float32Array([-20, 0, 0, 20, 0, 0])}
219
+ itemSize={3}
220
+ />
221
+ </bufferGeometry>
222
+ <lineBasicMaterial color="#ef4444" opacity={0.3} transparent />
223
+ </line>
224
+
225
+ {/* Y axis - green */}
226
+ <line>
227
+ <bufferGeometry>
228
+ <bufferAttribute
229
+ attach="attributes-position"
230
+ count={2}
231
+ array={new Float32Array([0, -20, 0, 0, 20, 0])}
232
+ itemSize={3}
233
+ />
234
+ </bufferGeometry>
235
+ <lineBasicMaterial color="#22c55e" opacity={0.3} transparent />
236
+ </line>
237
+
238
+ {/* Z axis - blue */}
239
+ <line>
240
+ <bufferGeometry>
241
+ <bufferAttribute
242
+ attach="attributes-position"
243
+ count={2}
244
+ array={new Float32Array([0, 0, -20, 0, 0, 20])}
245
+ itemSize={3}
246
+ />
247
+ </bufferGeometry>
248
+ <lineBasicMaterial color="#3b82f6" opacity={0.3} transparent />
249
+ </line>
250
+ </group>
251
+ );
252
+ }
253
+
254
+ // Mini-map camera synced with main view camera
255
+ function SyncedMiniMapCamera({
256
+ mainCameraPosition,
257
+ mainCameraTarget
258
+ }: {
259
+ mainCameraPosition: [number, number, number];
260
+ mainCameraTarget: [number, number, number];
261
+ }) {
262
+ const { camera } = useThree();
263
+
264
+ useFrame(() => {
265
+ // Calculate direction from main camera
266
+ const dir = new THREE.Vector3(
267
+ mainCameraPosition[0] - mainCameraTarget[0],
268
+ mainCameraPosition[1] - mainCameraTarget[1],
269
+ mainCameraPosition[2] - mainCameraTarget[2]
270
+ );
271
+
272
+ // Scale the distance for the mini-map (fixed overview distance)
273
+ const distance = 30;
274
+ dir.normalize().multiplyScalar(distance);
275
+
276
+ // Position mini-map camera in same direction but at fixed distance
277
+ camera.position.set(
278
+ mainCameraTarget[0] + dir.x,
279
+ mainCameraTarget[1] + dir.y,
280
+ mainCameraTarget[2] + dir.z
281
+ );
282
+ camera.lookAt(mainCameraTarget[0], mainCameraTarget[1], mainCameraTarget[2]);
283
+ camera.updateProjectionMatrix();
284
+ });
285
+
286
+ return null;
287
+ }
288
+
289
+ export default function MiniMap3D({
290
+ width = 180,
291
+ height = 140,
292
+ data,
293
+ colorBy,
294
+ cameraPosition,
295
+ cameraTarget,
296
+ }: MiniMap3DProps) {
297
+
298
+ if (data.length === 0) return null;
299
+
300
+ // Calculate canvas height (subtract header height only)
301
+ const headerHeight = 24;
302
+ const canvasHeight = height - headerHeight;
303
+
304
+ return (
305
+ <div className="minimap-container minimap-3d" title="3D overview showing your current camera position (orange) and viewing direction in the model space">
306
+ <div className="minimap-header">
307
+ <span className="minimap-title">VIEWPORT</span>
308
+ </div>
309
+ <Canvas
310
+ style={{ width, height: canvasHeight }}
311
+ camera={{
312
+ position: [20, 15, 20],
313
+ fov: 50,
314
+ near: 0.1,
315
+ far: 1000
316
+ }}
317
+ dpr={[1, 1.5]}
318
+ gl={{ antialias: true, alpha: true }}
319
+ >
320
+ <color attach="background" args={['#0d1117']} />
321
+
322
+ {/* Ambient light */}
323
+ <ambientLight intensity={0.6} />
324
+
325
+ {/* Point cloud */}
326
+ <MiniMapPoints data={data} colorBy={colorBy} />
327
+
328
+ {/* Camera indicator showing user's current view */}
329
+ <CameraIndicator position={cameraPosition} target={cameraTarget} />
330
+
331
+ {/* Axis helper for orientation */}
332
+ <AxisHelper />
333
+
334
+ {/* Mini-map camera synced with main view direction */}
335
+ <SyncedMiniMapCamera
336
+ mainCameraPosition={cameraPosition}
337
+ mainCameraTarget={cameraTarget}
338
+ />
339
+ </Canvas>
340
+ </div>
341
+ );
342
+ }
frontend/src/components/visualizations/NetworkGraph.tsx CHANGED
@@ -193,7 +193,6 @@ export default function NetworkGraph({
193
  setLinks(e.data.result.links);
194
  setIsCalculating(false);
195
  } else if (e.data.type === 'error') {
196
- console.error('Worker error:', e.data.error);
197
  setIsCalculating(false);
198
  }
199
  };
 
193
  setLinks(e.data.result.links);
194
  setIsCalculating(false);
195
  } else if (e.data.type === 'error') {
 
196
  setIsCalculating(false);
197
  }
198
  };
frontend/src/components/visualizations/ScatterPlot.css CHANGED
@@ -2,11 +2,22 @@
2
 
3
  .scatter-plot-container {
4
  position: relative;
5
- font-family: system-ui, -apple-system, sans-serif;
 
6
  }
7
 
8
  .scatter-plot-svg {
9
  display: block;
 
 
 
 
 
 
 
 
 
 
10
  background: #fafafa;
11
  }
12
 
 
2
 
3
  .scatter-plot-container {
4
  position: relative;
5
+ font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
6
+ overflow: visible;
7
  }
8
 
9
  .scatter-plot-svg {
10
  display: block;
11
+ background: var(--bg-tertiary, #1a1a1a);
12
+ }
13
+
14
+ /* Dark theme support */
15
+ [data-theme="dark"] .scatter-plot-svg {
16
+ background: #0a0a0a;
17
+ }
18
+
19
+ [data-theme="light"] .scatter-plot-svg,
20
+ :root:not([data-theme="dark"]) .scatter-plot-svg {
21
  background: #fafafa;
22
  }
23
 
frontend/src/components/visualizations/ScatterPlot.tsx CHANGED
@@ -1,7 +1,9 @@
1
  import React, { useMemo, useRef, useEffect, useState, useCallback } from 'react';
2
  import * as d3 from 'd3';
3
  import { ModelPoint } from '../../types';
 
4
  import './ScatterPlot.css';
 
5
 
6
  interface ScatterPlotProps {
7
  width: number;
@@ -36,8 +38,8 @@ export default function ScatterPlot({
36
 
37
  // Performance-optimized sampling with Level of Detail (LOD)
38
  const sampledData = useMemo(() => {
39
- // Reduced render limit for better performance (was 25000)
40
- const renderLimit = 10000;
41
 
42
  // LOD: Reduce further when zoomed out
43
  const lodFactor = transform.k < 1 ? 0.5 : 1; // Show 50% when zoomed out
@@ -156,6 +158,17 @@ export default function ScatterPlot({
156
  }
157
  }, []);
158
 
 
 
 
 
 
 
 
 
 
 
 
159
  // Debounced tooltip update
160
  const showTooltip = useCallback((d: ModelPoint, x: number, y: number) => {
161
  if (!gRef.current) return;
@@ -177,7 +190,7 @@ export default function ScatterPlot({
177
  { text: d.model_id.length > 35 ? d.model_id.substring(0, 35) + '...' : d.model_id, bold: true },
178
  { text: `Library: ${d.library_name || 'N/A'}` },
179
  { text: `Pipeline: ${d.pipeline_tag || 'N/A'}` },
180
- { text: `↓ ${d.downloads.toLocaleString()} | ${d.likes.toLocaleString()}` },
181
  { text: 'Click for details', small: true }
182
  ];
183
 
@@ -553,6 +566,21 @@ export default function ScatterPlot({
553
  Showing {sampledData.length.toLocaleString()} of {data.length.toLocaleString()} points
554
  </div>
555
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  </div>
557
  );
558
  }
 
1
  import React, { useMemo, useRef, useEffect, useState, useCallback } from 'react';
2
  import * as d3 from 'd3';
3
  import { ModelPoint } from '../../types';
4
+ import MiniMap from './MiniMap';
5
  import './ScatterPlot.css';
6
+ import './MiniMap.css';
7
 
8
  interface ScatterPlotProps {
9
  width: number;
 
38
 
39
  // Performance-optimized sampling with Level of Detail (LOD)
40
  const sampledData = useMemo(() => {
41
+ // Increased render limit to support full dataset (using Canvas for performance)
42
+ const renderLimit = 150000;
43
 
44
  // LOD: Reduce further when zoomed out
45
  const lodFactor = transform.k < 1 ? 0.5 : 1; // Show 50% when zoomed out
 
158
  }
159
  }, []);
160
 
161
+ // Handler for mini-map viewport changes
162
+ const handleMiniMapViewportChange = useCallback((newTransform: d3.ZoomTransform) => {
163
+ if (svgRef.current && zoomRef.current) {
164
+ d3.select(svgRef.current)
165
+ .transition()
166
+ .duration(300)
167
+ .ease(d3.easeCubicOut)
168
+ .call(zoomRef.current.transform as any, newTransform);
169
+ }
170
+ }, []);
171
+
172
  // Debounced tooltip update
173
  const showTooltip = useCallback((d: ModelPoint, x: number, y: number) => {
174
  if (!gRef.current) return;
 
190
  { text: d.model_id.length > 35 ? d.model_id.substring(0, 35) + '...' : d.model_id, bold: true },
191
  { text: `Library: ${d.library_name || 'N/A'}` },
192
  { text: `Pipeline: ${d.pipeline_tag || 'N/A'}` },
193
+ { text: `Downloads: ${d.downloads.toLocaleString()} | Likes: ${d.likes.toLocaleString()}` },
194
  { text: 'Click for details', small: true }
195
  ];
196
 
 
566
  Showing {sampledData.length.toLocaleString()} of {data.length.toLocaleString()} points
567
  </div>
568
  )}
569
+
570
+ {/* Mini-map / Overview Map */}
571
+ <MiniMap
572
+ width={180}
573
+ height={140}
574
+ data={data}
575
+ colorBy={colorBy}
576
+ mainWidth={width}
577
+ mainHeight={height}
578
+ mainMargin={margin}
579
+ transform={transform}
580
+ onViewportChange={handleMiniMapViewportChange}
581
+ xScaleBase={xScaleBase}
582
+ yScaleBase={yScaleBase}
583
+ />
584
  </div>
585
  );
586
  }
frontend/src/components/visualizations/ScatterPlot3D.css ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .scatter-3d-container {
2
+ width: 100%;
3
+ height: 100%;
4
+ position: relative;
5
+ overflow: visible;
6
+ }
7
+
8
+ .scatter-3d-empty {
9
+ width: 100%;
10
+ height: 100%;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ font-size: 1.1rem;
15
+ }
16
+
17
+ .scatter-3d-empty.dark {
18
+ color: #888;
19
+ }
20
+
21
+ .scatter-3d-empty.light {
22
+ color: #666;
23
+ }
24
+
frontend/src/components/visualizations/ScatterPlot3D.tsx CHANGED
@@ -1,272 +1,256 @@
1
- import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
2
- import { Canvas, useFrame, useThree } from '@react-three/fiber';
3
- import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
4
  import * as THREE from 'three';
5
  import { ModelPoint } from '../../types';
6
- import { createSpatialIndex } from '../../utils/rendering/spatialIndex';
7
- import { adaptiveSampleByDistance } from '../../utils/rendering/frustumCulling';
8
-
9
- // WebGL context loss recovery
10
- const handleWebGLContextLoss = (event: Event) => {
11
- event.preventDefault();
12
- // Context will be restored automatically by the browser
13
- };
14
-
15
- const handleWebGLContextRestored = () => {
16
- // Context restored - component will re-render automatically
17
- console.info('WebGL context restored');
18
- };
19
 
20
  interface ScatterPlot3DProps {
21
  data: ModelPoint[];
22
  colorBy: string;
23
  sizeBy: string;
 
24
  onPointClick?: (model: ModelPoint) => void;
25
  hoveredModel?: ModelPoint | null;
26
  onHover?: (model: ModelPoint | null, position?: { x: number; y: number }) => void;
27
  }
28
 
29
- function getModelColor(model: ModelPoint, colorBy: string, colorScale: any): string {
30
- if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
31
- const value = colorBy === 'library_name'
32
- ? (model.library_name || 'unknown')
33
- : (model.pipeline_tag || 'unknown');
34
- return colorScale.get(value) || '#999999';
35
- } else {
36
- const val = colorBy === 'downloads' ? model.downloads : model.likes;
37
- const logVal = Math.log10(val + 1);
38
- return colorScale(logVal);
39
- }
40
- }
41
-
42
- function getPointSize(model: ModelPoint, sizeBy: string): number {
43
- if (sizeBy === 'none') return 0.02;
44
- const val = sizeBy === 'downloads' ? model.downloads : model.likes;
45
- const logVal = Math.log10(val + 1);
46
- return 0.01 + (logVal / 7) * 0.04;
47
- }
48
-
49
- function Points({
50
  data,
51
  colorBy,
52
  sizeBy,
53
- onPointClick,
54
- onHover
55
- }: ScatterPlot3DProps) {
56
- const meshRef = useRef<THREE.InstancedMesh>(null);
57
- const [hovered, setHovered] = useState<number | null>(null);
58
- const { camera, size } = useThree();
59
- const [visiblePoints, setVisiblePoints] = useState<ModelPoint[]>(data);
60
- const frameCount = useRef(0);
 
 
 
 
 
 
 
 
 
 
61
 
62
- const colorScale = useMemo(() => {
 
 
 
 
 
 
 
63
  if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
64
- const categories = new Set(data.map((d) =>
65
  colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown')
66
- ));
67
- const colors = [
68
- '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
69
- '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'
70
- ];
71
- const scale = new Map();
72
- Array.from(categories).forEach((cat, i) => {
73
- scale.set(cat, colors[i % colors.length]);
74
- });
75
- return scale;
76
- } else {
77
- return (logVal: number) => {
78
- const t = Math.min(Math.max(logVal / 7, 0), 1);
79
- if (t < 0.5) {
80
- const tt = t * 2;
81
- return `rgb(${Math.floor(tt * 255)}, ${Math.floor(tt * 255)}, ${Math.floor((1 - tt) * 255)})`;
 
 
 
82
  } else {
83
- const tt = (t - 0.5) * 2;
84
- return `rgb(${Math.floor(255)}, ${Math.floor((1 - tt) * 255)}, 0)`;
85
  }
86
- };
87
  }
88
- }, [data, colorBy]);
89
-
90
- const { positions, colors, sizes, models } = useMemo(() => {
91
- const positions: number[] = [];
92
- const colors: number[] = [];
93
- const sizes: number[] = [];
94
- const models: ModelPoint[] = [];
95
-
96
- visiblePoints.forEach((model) => {
97
- positions.push(model.x, model.y, model.z);
98
 
99
- const color = getModelColor(model, colorBy, colorScale);
100
- const threeColor = new THREE.Color(color);
101
- colors.push(threeColor.r, threeColor.g, threeColor.b);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- const size = getPointSize(model, sizeBy);
104
- sizes.push(size);
105
 
106
- models.push(model);
107
- });
108
-
109
- return { positions, colors, sizes, models };
110
- }, [visiblePoints, colorBy, sizeBy, colorScale]);
111
-
112
- useEffect(() => {
113
- if (!meshRef.current || positions.length === 0) return;
114
-
115
- const tempObject = new THREE.Object3D();
116
- const tempColor = new THREE.Color();
117
- const count = Math.floor(positions.length / 3);
118
-
119
- for (let i = 0; i < count; i++) {
120
- tempObject.position.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);
121
- tempObject.scale.setScalar(sizes[i]);
122
- tempObject.updateMatrix();
123
- meshRef.current.setMatrixAt(i, tempObject.matrix);
124
 
125
- tempColor.setRGB(colors[i * 3], colors[i * 3 + 1], colors[i * 3 + 2]);
126
- meshRef.current.setColorAt(i, tempColor);
127
- }
128
-
129
- meshRef.current.count = count;
130
- meshRef.current.instanceMatrix.needsUpdate = true;
131
- if (meshRef.current.instanceColor) {
132
- meshRef.current.instanceColor.needsUpdate = true;
133
- }
134
- }, [positions, colors, sizes]);
135
-
136
- useEffect(() => {
137
- if (!meshRef.current || positions.length === 0) return;
138
-
139
- const tempObject = new THREE.Object3D();
140
- const count = Math.floor(positions.length / 3);
141
-
142
- for (let i = 0; i < count; i++) {
143
- const scale = i === hovered ? sizes[i] * 2 : sizes[i];
144
- tempObject.position.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);
145
- tempObject.scale.setScalar(scale);
146
- tempObject.updateMatrix();
147
- meshRef.current.setMatrixAt(i, tempObject.matrix);
148
- }
149
-
150
- meshRef.current.instanceMatrix.needsUpdate = true;
151
- }, [hovered, positions, sizes]);
152
-
153
- useFrame(() => {
154
- frameCount.current++;
155
- if (frameCount.current % 10 !== 0) return;
156
-
157
- try {
158
- const sampled = adaptiveSampleByDistance(
159
- data,
160
- camera as THREE.Camera,
161
- 1.0,
162
- 50
163
- );
164
 
165
- // Reduced from 100000 to prevent WebGL context loss
166
- const MAX_RENDER_POINTS = 50000;
167
- if (sampled.length > MAX_RENDER_POINTS) {
168
- const step = Math.ceil(sampled.length / MAX_RENDER_POINTS);
169
- const finalSampled: ModelPoint[] = [];
170
- for (let i = 0; i < sampled.length; i += step) {
171
- finalSampled.push(sampled[i]);
172
- }
173
- setVisiblePoints(finalSampled);
174
  } else {
175
- setVisiblePoints(sampled);
176
- }
177
- } catch (error) {
178
- // Silently handle WebGL errors to prevent console spam
179
- if (error instanceof Error && error.message.includes('WebGL')) {
180
- return;
181
  }
182
- throw error;
183
- }
184
- });
185
-
186
- const handlePointerMove = useCallback((event: any) => {
187
- event.stopPropagation();
188
- const instanceId = event.instanceId;
189
 
190
- if (instanceId !== undefined && instanceId !== hovered) {
191
- setHovered(instanceId);
192
-
193
- if (onHover && instanceId < models.length) {
194
- const model = models[instanceId];
195
- const vector = new THREE.Vector3(
196
- positions[instanceId * 3],
197
- positions[instanceId * 3 + 1],
198
- positions[instanceId * 3 + 2]
199
- );
200
- vector.project(camera as THREE.Camera);
201
-
202
- const x = (vector.x * 0.5 + 0.5) * size.width;
203
- const y = (-vector.y * 0.5 + 0.5) * size.height;
204
-
205
- onHover(model, { x, y });
206
- }
207
- }
208
- }, [hovered, onHover, models, positions, camera, size]);
209
-
210
- const handlePointerOut = useCallback(() => {
211
- setHovered(null);
212
- if (onHover) {
213
- onHover(null);
214
- }
215
- }, [onHover]);
216
 
217
- const handleClick = useCallback((event: any) => {
218
- event.stopPropagation();
219
- const instanceId = event.instanceId;
220
 
221
- if (onPointClick && instanceId !== undefined && instanceId < models.length) {
222
- onPointClick(models[instanceId]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  }
224
- }, [onPointClick, models]);
225
 
226
- if (visiblePoints.length === 0) return null;
227
 
228
- // Reduced max instances to prevent WebGL context loss
229
- const maxInstances = Math.min(50000, Math.max(visiblePoints.length, 1000));
230
-
231
  return (
232
- <instancedMesh
233
- ref={meshRef}
234
- args={[undefined, undefined, maxInstances]}
235
- frustumCulled={true}
236
- onPointerMove={handlePointerMove}
237
- onPointerOut={handlePointerOut}
238
  onClick={handleClick}
239
- >
240
- <sphereGeometry args={[1, 8, 8]} />
241
- <meshBasicMaterial vertexColors />
242
- </instancedMesh>
243
  );
244
  }
245
 
246
- export default function ScatterPlot3D(props: ScatterPlot3DProps) {
247
- const { data } = props;
248
- const canvasRef = useRef<HTMLDivElement>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
- useMemo(() => {
251
- if (data.length > 0) {
252
- createSpatialIndex(data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  }
254
- }, [data]);
255
 
256
- // Add WebGL context loss handlers
257
  useEffect(() => {
258
- const canvas = canvasRef.current?.querySelector('canvas');
259
- if (canvas) {
260
- canvas.addEventListener('webglcontextlost', handleWebGLContextLoss);
261
- canvas.addEventListener('webglcontextrestored', handleWebGLContextRestored);
262
-
263
- return () => {
264
- canvas.removeEventListener('webglcontextlost', handleWebGLContextLoss);
265
- canvas.removeEventListener('webglcontextrestored', handleWebGLContextRestored);
266
- };
267
- }
 
 
 
 
268
  }, []);
269
 
 
270
  const bounds = useMemo(() => {
271
  if (data.length === 0) {
272
  return { center: [0, 0, 0] as [number, number, number], radius: 10 };
@@ -276,78 +260,96 @@ export default function ScatterPlot3D(props: ScatterPlot3DProps) {
276
  let minY = Infinity, maxY = -Infinity;
277
  let minZ = Infinity, maxZ = -Infinity;
278
 
279
- data.forEach((d) => {
280
- minX = Math.min(minX, d.x);
281
- maxX = Math.max(maxX, d.x);
282
- minY = Math.min(minY, d.y);
283
- maxY = Math.max(maxY, d.y);
284
- minZ = Math.min(minZ, d.z);
285
- maxZ = Math.max(maxZ, d.z);
286
- });
 
 
 
 
 
 
 
 
287
 
288
  const center: [number, number, number] = [
289
  (minX + maxX) / 2,
290
  (minY + maxY) / 2,
291
- (minZ + maxZ) / 2,
292
  ];
293
-
294
- const radius = Math.max(
295
- maxX - minX,
296
- maxY - minY,
297
- maxZ - minZ
298
- ) / 2;
299
 
300
  return { center, radius };
301
  }, [data]);
302
 
 
 
 
 
 
 
 
 
303
  return (
304
- <div ref={canvasRef} style={{ width: '100%', height: '100%', background: 'var(--background-color)' }}>
305
  <Canvas
306
- key="scatter-3d-canvas"
307
- gl={{
308
- antialias: false,
 
309
  powerPreference: 'high-performance',
310
  preserveDrawingBuffer: false,
311
  failIfMajorPerformanceCaveat: false,
312
  }}
313
- performance={{ min: 0.5 }}
314
  onCreated={({ gl }) => {
315
- // Suppress WebGL context loss errors
316
- gl.domElement.addEventListener('webglcontextlost', (e) => {
317
- e.preventDefault();
318
  });
 
 
 
 
 
 
 
 
 
 
 
319
  }}
320
  >
321
- <PerspectiveCamera
322
- makeDefault
323
- position={[
324
- bounds.center[0] + bounds.radius * 1.5,
325
- bounds.center[1] + bounds.radius * 1.5,
326
- bounds.center[2] + bounds.radius * 1.5,
327
- ]}
328
- fov={60}
329
- near={0.1}
330
- far={bounds.radius * 10}
331
- />
332
 
333
  <OrbitControls
334
  target={bounds.center}
335
- enableDamping
336
  dampingFactor={0.05}
337
- minDistance={bounds.radius * 0.5}
338
- maxDistance={bounds.radius * 5}
 
339
  />
340
 
341
- <ambientLight intensity={0.8} />
342
- <directionalLight position={[10, 10, 5]} intensity={0.5} />
343
-
344
- <Points {...props} />
345
 
346
- <gridHelper
347
- args={[bounds.radius * 4, 20]}
348
- position={[bounds.center[0], bounds.center[1] - bounds.radius, bounds.center[2]]}
349
- />
350
  </Canvas>
 
 
 
 
 
 
 
 
351
  </div>
352
  );
353
  }
 
1
+ import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
2
+ import { Canvas, useThree, useFrame } from '@react-three/fiber';
3
+ import { OrbitControls } from '@react-three/drei';
4
  import * as THREE from 'three';
5
  import { ModelPoint } from '../../types';
6
+ import { getCategoricalColorMap, getContinuousColorScale, getDepthColorScale } from '../../utils/rendering/colors';
7
+ import MiniMap3D from './MiniMap3D';
8
+ import './ScatterPlot3D.css';
9
+ import './MiniMap.css';
 
 
 
 
 
 
 
 
 
10
 
11
  interface ScatterPlot3DProps {
12
  data: ModelPoint[];
13
  colorBy: string;
14
  sizeBy: string;
15
+ colorScheme?: string;
16
  onPointClick?: (model: ModelPoint) => void;
17
  hoveredModel?: ModelPoint | null;
18
  onHover?: (model: ModelPoint | null, position?: { x: number; y: number }) => void;
19
  }
20
 
21
+ function ColoredPoints({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  data,
23
  colorBy,
24
  sizeBy,
25
+ colorScheme = 'viridis',
26
+ onPointClick,
27
+ isDarkMode = true
28
+ }: ScatterPlot3DProps & { isDarkMode?: boolean }) {
29
+ const pointsRef = useRef<THREE.Points>(null);
30
+ const modelLookupRef = useRef<ModelPoint[]>([]);
31
+ const { raycaster, camera, gl } = useThree();
32
+
33
+ // Sample and prepare data
34
+ const geometryData = useMemo(() => {
35
+ // Increased limit to support full dataset (150k models)
36
+ const MAX_POINTS = 150000;
37
+ const step = data.length > MAX_POINTS ? Math.ceil(data.length / MAX_POINTS) : 1;
38
+ const sampled: ModelPoint[] = [];
39
+
40
+ for (let i = 0; i < data.length && sampled.length < MAX_POINTS; i += step) {
41
+ sampled.push(data[i]);
42
+ }
43
 
44
+ const count = sampled.length;
45
+ const positions = new Float32Array(count * 3);
46
+ const colors = new Float32Array(count * 3);
47
+ const sizes = new Float32Array(count);
48
+
49
+ // Calculate color scale
50
+ let colorScale: any = () => '#4a90e2';
51
+
52
  if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
53
+ const categories = Array.from(new Set(sampled.map((d) =>
54
  colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown')
55
+ )));
56
+ const colorSchemeType = colorBy === 'library_name' ? 'library' : 'pipeline';
57
+ colorScale = getCategoricalColorMap(categories, colorSchemeType);
58
+ } else if (colorBy === 'downloads' || colorBy === 'likes') {
59
+ const values = sampled.map((d) => colorBy === 'downloads' ? d.downloads : d.likes);
60
+ if (values.length > 0) {
61
+ const min = Math.min(...values);
62
+ const max = Math.max(...values);
63
+ colorScale = getContinuousColorScale(min, max, colorScheme as any, true);
64
+ }
65
+ } else if (colorBy === 'family_depth') {
66
+ const depths = sampled.map((d) => d.family_depth ?? 0);
67
+ if (depths.length > 0) {
68
+ const maxDepth = Math.max(...depths, 1);
69
+ const uniqueDepths = new Set(depths);
70
+
71
+ if (uniqueDepths.size <= 2 && maxDepth === 0) {
72
+ const categories = Array.from(new Set(sampled.map((d) => d.library_name || 'unknown')));
73
+ colorScale = getCategoricalColorMap(categories, 'library');
74
  } else {
75
+ colorScale = getDepthColorScale(maxDepth, isDarkMode);
 
76
  }
77
+ }
78
  }
79
+
80
+ // Fill arrays
81
+ sampled.forEach((model, idx) => {
82
+ positions[idx * 3] = model.x;
83
+ positions[idx * 3 + 1] = model.y;
84
+ positions[idx * 3 + 2] = model.z;
 
 
 
 
85
 
86
+ let colorHex: string;
87
+ if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
88
+ const value = colorBy === 'library_name'
89
+ ? (model.library_name || 'unknown')
90
+ : (model.pipeline_tag || 'unknown');
91
+ colorHex = colorScale instanceof Map ? colorScale.get(value) || '#4a90e2' : '#4a90e2';
92
+ } else if (colorBy === 'downloads' || colorBy === 'likes') {
93
+ const val = colorBy === 'downloads' ? model.downloads : model.likes;
94
+ colorHex = typeof colorScale === 'function' ? colorScale(val) : '#4a90e2';
95
+ } else if (colorBy === 'family_depth') {
96
+ if (colorScale instanceof Map) {
97
+ const value = model.library_name || 'unknown';
98
+ colorHex = colorScale.get(value) || '#4a90e2';
99
+ } else if (typeof colorScale === 'function') {
100
+ colorHex = colorScale(model.family_depth ?? 0);
101
+ } else {
102
+ colorHex = '#4a90e2';
103
+ }
104
+ } else {
105
+ colorHex = '#4a90e2';
106
+ }
107
 
108
+ const color = new THREE.Color(colorHex);
 
109
 
110
+ // Preserve original vibrant colors - no washing out
111
+ // The colors from our color utility are already optimized for dark mode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ colors[idx * 3] = color.r;
114
+ colors[idx * 3 + 1] = color.g;
115
+ colors[idx * 3 + 2] = color.b;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
+ // Calculate size
118
+ const baseSize = sizeBy === 'none' ? 8 : 6;
119
+ if (sizeBy === 'downloads' || sizeBy === 'likes') {
120
+ const val = sizeBy === 'downloads' ? model.downloads : model.likes;
121
+ const logVal = Math.log10(val + 1);
122
+ sizes[idx] = baseSize + (logVal / 7) * 12;
 
 
 
123
  } else {
124
+ sizes[idx] = baseSize;
 
 
 
 
 
125
  }
126
+ });
 
 
 
 
 
 
127
 
128
+ modelLookupRef.current = sampled;
129
+ return { positions, colors, sizes, count };
130
+ }, [data, colorBy, sizeBy, colorScheme, isDarkMode]);
131
+
132
+ // Create geometry
133
+ const geometry = useMemo(() => {
134
+ const geo = new THREE.BufferGeometry();
135
+ geo.setAttribute('position', new THREE.BufferAttribute(geometryData.positions, 3));
136
+ geo.setAttribute('color', new THREE.BufferAttribute(geometryData.colors, 3));
137
+ geo.setAttribute('size', new THREE.BufferAttribute(geometryData.sizes, 1));
138
+ return geo;
139
+ }, [geometryData]);
140
+
141
+ // Create material
142
+ const material = useMemo(() => {
143
+ return new THREE.PointsMaterial({
144
+ size: 0.15,
145
+ vertexColors: true,
146
+ sizeAttenuation: true,
147
+ transparent: true,
148
+ opacity: 0.9,
149
+ });
150
+ }, []);
 
 
 
151
 
152
+ // Handle click
153
+ const handleClick = (event: any) => {
154
+ if (!onPointClick || !pointsRef.current) return;
155
 
156
+ // Get mouse position
157
+ const rect = gl.domElement.getBoundingClientRect();
158
+ const mouse = new THREE.Vector2(
159
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
160
+ -((event.clientY - rect.top) / rect.height) * 2 + 1
161
+ );
162
+
163
+ raycaster.setFromCamera(mouse, camera);
164
+ raycaster.params.Points = { threshold: 0.5 };
165
+
166
+ const intersects = raycaster.intersectObject(pointsRef.current);
167
+ if (intersects.length > 0 && intersects[0].index !== undefined) {
168
+ const idx = intersects[0].index;
169
+ if (idx < modelLookupRef.current.length) {
170
+ onPointClick(modelLookupRef.current[idx]);
171
+ }
172
  }
173
+ };
174
 
175
+ if (geometryData.count === 0) return null;
176
 
 
 
 
177
  return (
178
+ <points
179
+ ref={pointsRef}
180
+ geometry={geometry}
181
+ material={material}
 
 
182
  onClick={handleClick}
183
+ frustumCulled={false}
184
+ />
 
 
185
  );
186
  }
187
 
188
+ // Camera tracking component for mini-map
189
+ function CameraTracker({
190
+ onCameraUpdate
191
+ }: {
192
+ onCameraUpdate: (position: [number, number, number], target: [number, number, number]) => void
193
+ }) {
194
+ const { camera, controls } = useThree();
195
+
196
+ useFrame(() => {
197
+ if (camera && controls) {
198
+ const orbitControls = controls as any;
199
+ const position: [number, number, number] = [camera.position.x, camera.position.y, camera.position.z];
200
+ const target: [number, number, number] = orbitControls.target
201
+ ? [orbitControls.target.x, orbitControls.target.y, orbitControls.target.z]
202
+ : [0, 0, 0];
203
+ onCameraUpdate(position, target);
204
+ }
205
+ });
206
+
207
+ return null;
208
+ }
209
 
210
+ export default function ScatterPlot3D(props: ScatterPlot3DProps) {
211
+ const { data, colorBy } = props;
212
+
213
+ const [canvasBg, setCanvasBg] = useState(() => {
214
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
215
+ return isDark ? '#0a0a0a' : '#ffffff';
216
+ });
217
+ const [isDarkMode, setIsDarkMode] = useState(() => {
218
+ return document.documentElement.getAttribute('data-theme') === 'dark';
219
+ });
220
+
221
+ // Camera state for mini-map
222
+ const [cameraPosition, setCameraPosition] = useState<[number, number, number]>([0, 0, 10]);
223
+ const [cameraTarget, setCameraTarget] = useState<[number, number, number]>([0, 0, 0]);
224
+
225
+ // Throttle camera updates
226
+ const lastUpdateRef = useRef<number>(0);
227
+ const handleCameraUpdate = useCallback((position: [number, number, number], target: [number, number, number]) => {
228
+ const now = Date.now();
229
+ if (now - lastUpdateRef.current > 100) { // Update every 100ms
230
+ setCameraPosition(position);
231
+ setCameraTarget(target);
232
+ lastUpdateRef.current = now;
233
  }
234
+ }, []);
235
 
 
236
  useEffect(() => {
237
+ const updateBg = () => {
238
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
239
+ setCanvasBg(isDark ? '#0a0a0a' : '#ffffff');
240
+ setIsDarkMode(isDark);
241
+ };
242
+
243
+ updateBg();
244
+ const observer = new MutationObserver(updateBg);
245
+ observer.observe(document.documentElement, {
246
+ attributes: true,
247
+ attributeFilter: ['data-theme']
248
+ });
249
+
250
+ return () => observer.disconnect();
251
  }, []);
252
 
253
+ // Simple bounds calculation
254
  const bounds = useMemo(() => {
255
  if (data.length === 0) {
256
  return { center: [0, 0, 0] as [number, number, number], radius: 10 };
 
260
  let minY = Infinity, maxY = -Infinity;
261
  let minZ = Infinity, maxZ = -Infinity;
262
 
263
+ const step = Math.max(1, Math.floor(data.length / 1000));
264
+ for (let i = 0; i < data.length; i += step) {
265
+ const d = data[i];
266
+ if (isFinite(d.x) && isFinite(d.y) && isFinite(d.z)) {
267
+ minX = Math.min(minX, d.x);
268
+ maxX = Math.max(maxX, d.x);
269
+ minY = Math.min(minY, d.y);
270
+ maxY = Math.max(maxY, d.y);
271
+ minZ = Math.min(minZ, d.z);
272
+ maxZ = Math.max(maxZ, d.z);
273
+ }
274
+ }
275
+
276
+ if (!isFinite(minX)) {
277
+ return { center: [0, 0, 0] as [number, number, number], radius: 10 };
278
+ }
279
 
280
  const center: [number, number, number] = [
281
  (minX + maxX) / 2,
282
  (minY + maxY) / 2,
283
+ (minZ + maxZ) / 2
284
  ];
285
+ const size = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
286
+ const radius = Math.max(size / 2, 1);
 
 
 
 
287
 
288
  return { center, radius };
289
  }, [data]);
290
 
291
+ if (data.length === 0) {
292
+ return (
293
+ <div className={`scatter-3d-empty ${isDarkMode ? 'dark' : 'light'}`}>
294
+ No data to display
295
+ </div>
296
+ );
297
+ }
298
+
299
  return (
300
+ <div className="scatter-3d-container">
301
  <Canvas
302
+ dpr={[1, 1.5]}
303
+ gl={{
304
+ antialias: true,
305
+ alpha: false,
306
  powerPreference: 'high-performance',
307
  preserveDrawingBuffer: false,
308
  failIfMajorPerformanceCaveat: false,
309
  }}
 
310
  onCreated={({ gl }) => {
311
+ gl.domElement.addEventListener('webglcontextlost', (event) => {
312
+ event.preventDefault();
 
313
  });
314
+ gl.domElement.addEventListener('webglcontextrestored', () => {});
315
+ }}
316
+ camera={{
317
+ position: [
318
+ bounds.center[0] + bounds.radius * 0.5,
319
+ bounds.center[1] + bounds.radius * 0.5,
320
+ bounds.center[2] + bounds.radius * 0.5,
321
+ ],
322
+ fov: 45,
323
+ near: 0.1,
324
+ far: bounds.radius * 20
325
  }}
326
  >
327
+ <color attach="background" args={[canvasBg]} />
 
 
 
 
 
 
 
 
 
 
328
 
329
  <OrbitControls
330
  target={bounds.center}
331
+ enableDamping={true}
332
  dampingFactor={0.05}
333
+ minDistance={bounds.radius * 0.2}
334
+ maxDistance={bounds.radius * 4}
335
+ makeDefault
336
  />
337
 
338
+ <ambientLight intensity={1.0} />
 
 
 
339
 
340
+ <ColoredPoints {...props} isDarkMode={isDarkMode} />
341
+
342
+ <CameraTracker onCameraUpdate={handleCameraUpdate} />
343
+
344
  </Canvas>
345
+
346
+ {/* Mini-map / Overview Map */}
347
+ <MiniMap3D
348
+ data={data}
349
+ colorBy={colorBy}
350
+ cameraPosition={cameraPosition}
351
+ cameraTarget={cameraTarget}
352
+ />
353
  </div>
354
  );
355
  }