Add 150k model support, background computation, demo GIF, and UI improvements
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- README.md +4 -2
- backend/api/main.py +359 -0
- backend/api/routes/models.py +62 -0
- backend/scripts/precompute_background.py +414 -0
- frontend/package-lock.json +36 -3
- frontend/package.json +3 -0
- frontend/public/index.html +4 -0
- frontend/src/App.css +1646 -97
- frontend/src/App.tsx +413 -1063
- frontend/src/components/controls/IntegratedSearch.css +519 -0
- frontend/src/components/controls/IntegratedSearch.tsx +435 -0
- frontend/src/components/controls/SemanticSearch.css +278 -0
- frontend/src/components/controls/SemanticSearch.tsx +398 -0
- frontend/src/components/controls/VisualizationModeButtons.tsx +0 -1
- frontend/src/components/layout/SearchBar.tsx +4 -4
- frontend/src/components/modals/FileTree.css +0 -268
- frontend/src/components/modals/FileTree.tsx +0 -509
- frontend/src/components/modals/ModelModal.css +0 -533
- frontend/src/components/modals/ModelModal.tsx +0 -428
- frontend/src/components/ui/ColorLegend.css +88 -20
- frontend/src/components/ui/ColorLegend.tsx +158 -60
- frontend/src/components/ui/ErrorBoundary.css +62 -0
- frontend/src/components/ui/ErrorBoundary.tsx +8 -42
- frontend/src/components/ui/IntroModal.css +268 -0
- frontend/src/components/ui/IntroModal.tsx +121 -0
- frontend/src/components/ui/LiveModelCount.css +34 -0
- frontend/src/components/ui/LiveModelCount.tsx +13 -49
- frontend/src/components/ui/LiveModelCounter.css +165 -0
- frontend/src/components/ui/LiveModelCounter.tsx +226 -0
- frontend/src/components/ui/LoadingProgress.css +63 -0
- frontend/src/components/ui/LoadingProgress.tsx +35 -0
- frontend/src/components/ui/ModelCountTracker.tsx +2 -2
- frontend/src/components/ui/ModelPopup.css +317 -0
- frontend/src/components/ui/ModelPopup.tsx +243 -0
- frontend/src/components/ui/ModelTooltip.css +71 -0
- frontend/src/components/ui/ModelTooltip.tsx +20 -48
- frontend/src/components/ui/VirtualSearchResults.css +39 -0
- frontend/src/components/ui/VirtualSearchResults.tsx +6 -20
- frontend/src/components/visualizations/AdoptionCurve.css +138 -0
- frontend/src/components/visualizations/AdoptionCurve.tsx +447 -0
- frontend/src/components/visualizations/DistanceHeatmap.css +39 -0
- frontend/src/components/visualizations/DistanceHeatmap.tsx +7 -28
- frontend/src/components/visualizations/MiniMap.css +227 -0
- frontend/src/components/visualizations/MiniMap.tsx +317 -0
- frontend/src/components/visualizations/MiniMap3D.tsx +342 -0
- frontend/src/components/visualizations/NetworkGraph.tsx +0 -1
- frontend/src/components/visualizations/ScatterPlot.css +12 -1
- frontend/src/components/visualizations/ScatterPlot.tsx +31 -3
- frontend/src/components/visualizations/ScatterPlot3D.css +24 -0
- 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 |
-
##
|
| 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 |
+

|
| 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.
|
| 7857 |
-
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.
|
| 7858 |
-
"integrity": "sha512-
|
| 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(--
|
| 75 |
color: var(--text-primary);
|
| 76 |
padding: 1rem 2rem;
|
| 77 |
-
border-bottom: 1px solid var(--border-
|
| 78 |
-
box-shadow: var(--shadow-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
width: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
box-sizing: border-box;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
-
|
| 83 |
-
.
|
| 84 |
margin: 0;
|
| 85 |
-
|
| 86 |
-
font-weight: 600;
|
| 87 |
-
letter-spacing: -0.01em;
|
| 88 |
-
line-height: 1.3;
|
| 89 |
-
color: #ffffff;
|
| 90 |
}
|
| 91 |
-
|
| 92 |
-
.
|
| 93 |
-
|
| 94 |
-
text-decoration: none;
|
| 95 |
font-weight: 500;
|
| 96 |
-
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
-
|
| 99 |
-
.
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
-
|
| 103 |
-
.
|
| 104 |
-
display: flex;
|
| 105 |
-
gap: 0.75rem;
|
| 106 |
font-size: 0.85rem;
|
| 107 |
-
|
|
|
|
| 108 |
}
|
| 109 |
-
|
| 110 |
-
.
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
| 115 |
font-weight: 500;
|
| 116 |
-
|
| 117 |
}
|
| 118 |
-
|
| 119 |
-
.
|
| 120 |
-
|
|
|
|
| 121 |
}
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
============================================ */
|
| 126 |
-
.main-content {
|
| 127 |
display: flex;
|
| 128 |
-
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
-
|
| 131 |
-
.
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
.visualization {
|
| 140 |
flex: 1;
|
| 141 |
-
padding:
|
| 142 |
display: flex;
|
| 143 |
align-items: center;
|
| 144 |
justify-content: center;
|
| 145 |
background: var(--bg-primary);
|
| 146 |
-
overflow:
|
| 147 |
position: relative;
|
|
|
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
/* ============================================
|
| 151 |
SIDEBAR COMPONENTS
|
| 152 |
============================================ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
.sidebar h2 {
|
| 154 |
-
margin
|
| 155 |
-
font-size: 1.
|
| 156 |
font-weight: 600;
|
| 157 |
letter-spacing: -0.01em;
|
| 158 |
color: var(--text-primary);
|
| 159 |
}
|
| 160 |
|
| 161 |
.sidebar h3 {
|
| 162 |
-
font-size:
|
| 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:
|
| 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 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 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 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 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 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 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: #
|
| 347 |
-
color: #
|
| 348 |
border-radius: 0;
|
| 349 |
font-size: 0.8rem;
|
| 350 |
font-weight: 500;
|
| 351 |
-
border: 1px solid #
|
| 352 |
cursor: pointer;
|
| 353 |
transition: all var(--transition-base);
|
| 354 |
}
|
| 355 |
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
|
| 362 |
.filter-chip.active {
|
| 363 |
-
background: #
|
| 364 |
-
color:
|
| 365 |
-
border-color: #
|
| 366 |
}
|
| 367 |
|
| 368 |
.filter-chip .remove {
|
|
@@ -546,10 +1876,14 @@
|
|
| 546 |
position: absolute;
|
| 547 |
cursor: pointer;
|
| 548 |
inset: 0;
|
| 549 |
-
background-color: #
|
| 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 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
|
| 594 |
/* ============================================
|
| 595 |
ZOOM CONTROLS
|
|
@@ -634,10 +1968,9 @@
|
|
| 634 |
transition: all var(--transition-base);
|
| 635 |
}
|
| 636 |
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
}
|
| 641 |
|
| 642 |
.zoom-slider::-moz-range-thumb {
|
| 643 |
width: 16px;
|
|
@@ -649,10 +1982,9 @@
|
|
| 649 |
transition: all var(--transition-base);
|
| 650 |
}
|
| 651 |
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
}
|
| 656 |
|
| 657 |
.zoom-slider:disabled {
|
| 658 |
opacity: 0.5;
|
|
@@ -699,11 +2031,11 @@
|
|
| 699 |
|
| 700 |
.loading::after {
|
| 701 |
content: '';
|
| 702 |
-
width:
|
| 703 |
-
height:
|
| 704 |
-
border:
|
| 705 |
border-top-color: var(--accent-primary);
|
| 706 |
-
border-radius:
|
| 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 |
-
|
| 3 |
-
import
|
| 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
|
|
|
|
|
|
|
|
|
|
| 23 |
// Types & Utils
|
| 24 |
-
import { ModelPoint, Stats,
|
|
|
|
| 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 [
|
| 80 |
-
const [
|
|
|
|
| 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 [
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
const [
|
| 91 |
-
const [
|
| 92 |
-
const [
|
| 93 |
-
const [
|
| 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 [
|
| 99 |
-
const [
|
| 100 |
-
const [
|
| 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 |
-
|
| 106 |
-
const [
|
| 107 |
-
const [
|
| 108 |
-
const [
|
| 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
|
| 128 |
-
const [height, setHeight] = useState(window.innerHeight
|
| 129 |
|
| 130 |
useEffect(() => {
|
| 131 |
const handleResize = () => {
|
| 132 |
-
setWidth(window.innerWidth
|
| 133 |
-
setHeight(window.innerHeight
|
| 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 |
-
//
|
| 163 |
-
|
| 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 |
-
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
const debouncedFetchData = useMemo(
|
| 282 |
-
() => debounce(fetchData,
|
| 283 |
[fetchData]
|
| 284 |
);
|
| 285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
// Consolidated effect to handle both search and filter changes
|
|
|
|
|
|
|
|
|
|
| 287 |
useEffect(() => {
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
-
//
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
}
|
| 350 |
}
|
| 351 |
};
|
| 352 |
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
}, []);
|
| 355 |
|
| 356 |
-
// Fetch clusters
|
| 357 |
useEffect(() => {
|
| 358 |
-
const fetchClusters = async () => {
|
| 359 |
setClustersLoading(true);
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
}
|
| 371 |
};
|
| 372 |
|
| 373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
<
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 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 |
-
|
| 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 |
-
|
| 770 |
-
|
| 771 |
}}
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
fontSize: '0.9rem',
|
| 782 |
-
opacity: loading || data.length === 0 ? 0.5 : 1
|
| 783 |
}}
|
| 784 |
-
|
|
|
|
| 785 |
>
|
| 786 |
-
|
| 787 |
</button>
|
| 788 |
<button
|
| 789 |
-
onClick={
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
background: '#6c757d',
|
| 793 |
-
color: 'white',
|
| 794 |
-
border: 'none',
|
| 795 |
-
borderRadius: '0',
|
| 796 |
-
cursor: 'pointer',
|
| 797 |
-
fontWeight: '500',
|
| 798 |
-
fontSize: '0.9rem'
|
| 799 |
}}
|
| 800 |
-
|
|
|
|
| 801 |
>
|
| 802 |
-
|
| 803 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
</div>
|
| 805 |
-
|
|
|
|
| 806 |
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 904 |
-
|
| 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 |
-
|
| 918 |
-
<
|
| 919 |
-
|
| 920 |
-
</summary>
|
| 921 |
-
<div style={{ marginTop: '0.75rem' }}>
|
| 922 |
<select
|
| 923 |
-
value={
|
| 924 |
-
onChange={(e) =>
|
| 925 |
-
|
| 926 |
-
title="
|
| 927 |
>
|
| 928 |
-
<option value="
|
| 929 |
-
<option value="
|
|
|
|
|
|
|
|
|
|
| 930 |
</select>
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 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 |
-
|
| 1005 |
-
|
|
|
|
|
|
|
|
|
|
| 1006 |
)}
|
| 1007 |
</div>
|
| 1008 |
-
)}
|
| 1009 |
-
</details>
|
| 1010 |
|
| 1011 |
-
|
| 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 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
|
|
|
| 1046 |
</div>
|
| 1047 |
-
</label>
|
| 1048 |
|
| 1049 |
-
|
| 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 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 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 |
-
|
| 1169 |
-
<
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 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 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 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
|
| 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"
|
| 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:
|
| 4 |
-
border: 1px solid #
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
| 8 |
z-index: 100;
|
| 9 |
font-size: 0.875rem;
|
| 10 |
-
max-height:
|
| 11 |
overflow-y: auto;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
.legend-top-right {
|
|
@@ -32,11 +46,18 @@
|
|
| 32 |
}
|
| 33 |
|
| 34 |
.legend-header {
|
| 35 |
-
margin-bottom: 0.
|
| 36 |
-
padding-bottom: 0.
|
| 37 |
-
border-bottom: 1px solid #
|
| 38 |
-
font-size: 0.
|
| 39 |
-
color: #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 65 |
-
height:
|
| 66 |
-
border
|
| 67 |
-
border: 1px solid #ccc;
|
| 68 |
flex-shrink: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
.legend-label {
|
| 72 |
-
color: #
|
| 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:
|
| 88 |
-
border-radius: 0;
|
| 89 |
overflow: hidden;
|
| 90 |
-
border: 1px solid #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
const
|
| 48 |
-
|
| 49 |
-
label:
|
| 50 |
-
color:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
|
| 76 |
return (
|
| 77 |
<div className={`color-legend ${positionClass}`} style={{ width }}>
|
| 78 |
<div className="legend-header">
|
| 79 |
-
<strong>{
|
| 80 |
</div>
|
| 81 |
<div className="legend-content">
|
| 82 |
-
{isCategorical
|
| 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 |
-
|
| 64 |
-
|
| 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
|
| 78 |
-
<summary
|
| 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 |
-
<
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
{
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 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"
|
| 134 |
) : error && !currentCount ? (
|
| 135 |
-
<div className="count-error"
|
| 136 |
-
|
| 137 |
-
|
| 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
|
| 52 |
-
|
| 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">></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">></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;
|
| 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
|
| 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
|
| 129 |
{model.model_id}
|
| 130 |
</div>
|
| 131 |
|
| 132 |
-
<div
|
| 133 |
{model.library_name && (
|
| 134 |
-
<div
|
| 135 |
-
<span
|
| 136 |
</div>
|
| 137 |
)}
|
| 138 |
{model.pipeline_tag && (
|
| 139 |
-
<div
|
| 140 |
-
<span
|
| 141 |
</div>
|
| 142 |
)}
|
| 143 |
-
<div
|
| 144 |
-
<span
|
| 145 |
-
<span
|
| 146 |
</div>
|
| 147 |
{model.created_at && (
|
| 148 |
-
<div
|
| 149 |
-
<span
|
| 150 |
</div>
|
| 151 |
)}
|
| 152 |
{model.parent_model && (
|
| 153 |
-
<div
|
| 154 |
-
<span
|
| 155 |
</div>
|
| 156 |
)}
|
| 157 |
</div>
|
| 158 |
|
| 159 |
{details.loading && (
|
| 160 |
-
<div
|
| 161 |
Loading description...
|
| 162 |
</div>
|
| 163 |
)}
|
| 164 |
|
| 165 |
{truncatedDescription && (
|
| 166 |
-
<div
|
| 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
|
| 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 |
-
|
| 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
|
| 47 |
{result.library_name && (
|
| 48 |
-
<div
|
| 49 |
{result.library_name} {result.pipeline_tag && `• ${result.pipeline_tag}`}
|
| 50 |
</div>
|
| 51 |
)}
|
| 52 |
{(result.downloads || result.likes) && (
|
| 53 |
-
<div
|
| 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 |
-
|
| 49 |
-
|
| 50 |
-
top: 0,
|
| 51 |
-
left: 0,
|
| 52 |
-
width,
|
| 53 |
-
height,
|
| 54 |
-
pointerEvents: 'none',
|
| 55 |
-
zIndex: 1
|
| 56 |
-
}}
|
| 57 |
>
|
| 58 |
-
<div
|
| 59 |
-
|
| 60 |
-
|
| 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
|
| 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:
|
|
|
|
| 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 |
-
//
|
| 40 |
-
const renderLimit =
|
| 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:
|
| 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,
|
| 2 |
-
import { Canvas,
|
| 3 |
-
import { OrbitControls
|
| 4 |
import * as THREE from 'three';
|
| 5 |
import { ModelPoint } from '../../types';
|
| 6 |
-
import {
|
| 7 |
-
import
|
| 8 |
-
|
| 9 |
-
|
| 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
|
| 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 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
const
|
| 58 |
-
const
|
| 59 |
-
const
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
if (colorBy === 'library_name' || colorBy === 'pipeline_tag') {
|
| 64 |
-
const categories = new Set(
|
| 65 |
colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown')
|
| 66 |
-
));
|
| 67 |
-
const
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
} else {
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 82 |
} else {
|
| 83 |
-
|
| 84 |
-
return `rgb(${Math.floor(255)}, ${Math.floor((1 - tt) * 255)}, 0)`;
|
| 85 |
}
|
| 86 |
-
}
|
| 87 |
}
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
const models: ModelPoint[] = [];
|
| 95 |
-
|
| 96 |
-
visiblePoints.forEach((model) => {
|
| 97 |
-
positions.push(model.x, model.y, model.z);
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
const
|
| 104 |
-
sizes.push(size);
|
| 105 |
|
| 106 |
-
|
| 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 |
-
|
| 126 |
-
|
| 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 |
-
//
|
| 166 |
-
const
|
| 167 |
-
if (
|
| 168 |
-
const
|
| 169 |
-
const
|
| 170 |
-
|
| 171 |
-
finalSampled.push(sampled[i]);
|
| 172 |
-
}
|
| 173 |
-
setVisiblePoints(finalSampled);
|
| 174 |
} else {
|
| 175 |
-
|
| 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 |
-
|
| 183 |
-
}
|
| 184 |
-
});
|
| 185 |
-
|
| 186 |
-
const handlePointerMove = useCallback((event: any) => {
|
| 187 |
-
event.stopPropagation();
|
| 188 |
-
const instanceId = event.instanceId;
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
onHover(null);
|
| 214 |
-
}
|
| 215 |
-
}, [onHover]);
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
}
|
| 224 |
-
}
|
| 225 |
|
| 226 |
-
if (
|
| 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 |
-
<
|
| 233 |
-
ref={
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
onPointerMove={handlePointerMove}
|
| 237 |
-
onPointerOut={handlePointerOut}
|
| 238 |
onClick={handleClick}
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
<meshBasicMaterial vertexColors />
|
| 242 |
-
</instancedMesh>
|
| 243 |
);
|
| 244 |
}
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
}
|
| 254 |
-
}, [
|
| 255 |
|
| 256 |
-
// Add WebGL context loss handlers
|
| 257 |
useEffect(() => {
|
| 258 |
-
const
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 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 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 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
|
| 305 |
<Canvas
|
| 306 |
-
|
| 307 |
-
gl={{
|
| 308 |
-
antialias:
|
|
|
|
| 309 |
powerPreference: 'high-performance',
|
| 310 |
preserveDrawingBuffer: false,
|
| 311 |
failIfMajorPerformanceCaveat: false,
|
| 312 |
}}
|
| 313 |
-
performance={{ min: 0.5 }}
|
| 314 |
onCreated={({ gl }) => {
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
e.preventDefault();
|
| 318 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
}}
|
| 320 |
>
|
| 321 |
-
<
|
| 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.
|
| 338 |
-
maxDistance={bounds.radius *
|
|
|
|
| 339 |
/>
|
| 340 |
|
| 341 |
-
<ambientLight intensity={0
|
| 342 |
-
<directionalLight position={[10, 10, 5]} intensity={0.5} />
|
| 343 |
-
|
| 344 |
-
<Points {...props} />
|
| 345 |
|
| 346 |
-
<
|
| 347 |
-
|
| 348 |
-
|
| 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 |
}
|