diff --git a/README.md b/README.md index 8683ccbb13fd43a2ef95188e93dea4939a5b6c1c..6f871d24712037e18dbecc3e97f248ba750116cd 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,13 @@ Many have observed that the development and deployment of generative machine lea 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. +![Demo](assets/demo.gif) + **Resources:** - **GitHub Repository**: [bendlaufer/ai-ecosystem](https://github.com/bendlaufer/ai-ecosystem) - Original research repository with analysis notebooks and datasets - **Hugging Face Project**: [modelbiome](https://huggingface.co/modelbiome) - Dataset and project page on Hugging Face Hub -## 🚀 Quick Start (New: Pre-Computed Data) +## Quick Start (Pre-Computed Data) This project now uses **pre-computed embeddings and coordinates** for instant startup: @@ -36,7 +38,7 @@ cd ../frontend npm install && npm start ``` -**Startup time:** ~5-10 seconds ⚡ +**Startup time:** ~5-10 seconds ### Option 2: Traditional Mode (Fallback) diff --git a/backend/api/main.py b/backend/api/main.py index ac638079f7eb50006e87c0aa384ce014d3eb06ba..1c1aa332a28356e2f54c4e79ef159631516ef829 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -696,6 +696,91 @@ async def get_family_stats(): } +@app.get("/api/family/top") +async def get_top_families( + limit: int = Query(50, ge=1, le=200, description="Maximum number of families to return"), + min_size: int = Query(2, ge=1, description="Minimum family size to include") +): + """ + Get top families by total lineage count (sum of all descendants). + Calculates the actual family tree size by traversing parent-child relationships. + """ + if deps.df is None: + raise DataNotLoadedError() + + df = deps.df + + # Build parent -> children mapping + children_map = {} + root_models = set() + + for idx, row in df.iterrows(): + model_id = str(row.get('model_id', '')) + parent_id = row.get('parent_model') + + if pd.isna(parent_id) or str(parent_id) == 'nan' or str(parent_id) == '': + root_models.add(model_id) + else: + parent_str = str(parent_id) + if parent_str not in children_map: + children_map[parent_str] = [] + children_map[parent_str].append(model_id) + + # For each root, count all descendants + def count_descendants(model_id: str, visited: set) -> int: + if model_id in visited: + return 0 + visited.add(model_id) + count = 1 # Count self + for child in children_map.get(model_id, []): + count += count_descendants(child, visited) + return count + + # Calculate family sizes + family_data = [] + for root in root_models: + visited = set() + total_count = count_descendants(root, visited) + if total_count >= min_size: + # Get organization from model_id + org = root.split('/')[0] if '/' in root else root + family_data.append({ + "root_model": root, + "organization": org, + "total_models": total_count, + "depth_count": len(visited) # Same as total for tree traversal + }) + + # Sort by total count descending + family_data.sort(key=lambda x: x['total_models'], reverse=True) + + # Also aggregate by organization (sum all families under same org) + org_totals = {} + for fam in family_data: + org = fam['organization'] + if org not in org_totals: + org_totals[org] = { + "organization": org, + "total_models": 0, + "family_count": 0, + "root_models": [] + } + org_totals[org]['total_models'] += fam['total_models'] + org_totals[org]['family_count'] += 1 + if len(org_totals[org]['root_models']) < 5: # Keep top 5 root models + org_totals[org]['root_models'].append(fam['root_model']) + + # Sort organizations by total models + top_orgs = sorted(org_totals.values(), key=lambda x: x['total_models'], reverse=True)[:limit] + + return { + "families": family_data[:limit], + "organizations": top_orgs, + "total_families": len(family_data), + "total_root_models": len(root_models) + } + + @app.get("/api/family/path/{model_id}") async def get_family_path( model_id: str, @@ -1028,6 +1113,118 @@ async def search_models( return {"results": results, "search_type": "basic", "query": search_query} +@app.get("/api/search/fuzzy") +async def fuzzy_search_models( + q: str = Query(..., min_length=2, description="Search query"), + limit: int = Query(50, ge=1, le=200, description="Maximum number of results"), + threshold: int = Query(60, ge=0, le=100, description="Minimum fuzzy match score (0-100)"), +): + """ + Fuzzy search for models using rapidfuzz. + Handles typos and partial matches across model names, libraries, and pipelines. + Returns results sorted by relevance score. + """ + if deps.df is None: + raise DataNotLoadedError() + + df = deps.df + + try: + from rapidfuzz import fuzz, process + from rapidfuzz.utils import default_process + + query_lower = q.lower().strip() + + # Prepare choices - combine model_id, library, and pipeline for searching + # Create a searchable string for each model + model_ids = df['model_id'].astype(str).tolist() + libraries = df.get('library_name', pd.Series([''] * len(df))).fillna('').astype(str).tolist() + pipelines = df.get('pipeline_tag', pd.Series([''] * len(df))).fillna('').astype(str).tolist() + + # Create search strings - just model_id for better fuzzy matching + # Library and pipeline are used for secondary filtering + search_strings = [m.lower() for m in model_ids] + + # Use rapidfuzz to find best matches + # WRatio is best for general fuzzy matching with typo tolerance + # It handles transpositions, insertions, deletions well + + # extract returns list of (match, score, index) + matches = process.extract( + query_lower, + search_strings, + scorer=fuzz.WRatio, + limit=limit * 3, # Get extra to filter by threshold and dedupe + score_cutoff=threshold, + processor=default_process + ) + + # Also try partial matching for substring searches + if len(matches) < limit: + partial_matches = process.extract( + query_lower, + search_strings, + scorer=fuzz.partial_ratio, + limit=limit * 2, + score_cutoff=threshold + 10, # Higher threshold for partial + processor=default_process + ) + # Add unique partial matches + seen_indices = {m[2] for m in matches} + for m in partial_matches: + if m[2] not in seen_indices: + matches.append(m) + seen_indices.add(m[2]) + + results = [] + seen_ids = set() + + for match_str, score, idx in matches: + if len(results) >= limit: + break + + model_id = model_ids[idx] + if model_id in seen_ids: + continue + seen_ids.add(model_id) + + row = df.iloc[idx] + + # Get coordinates + x = float(row.get('x', 0.0)) if 'x' in row else None + y = float(row.get('y', 0.0)) if 'y' in row else None + z = float(row.get('z', 0.0)) if 'z' in row else None + + results.append({ + "model_id": model_id, + "x": x, + "y": y, + "z": z, + "score": round(score, 1), + "library": row.get('library_name') if pd.notna(row.get('library_name')) else None, + "pipeline": row.get('pipeline_tag') if pd.notna(row.get('pipeline_tag')) else None, + "downloads": int(row.get('downloads', 0)), + "likes": int(row.get('likes', 0)), + "family_depth": int(row.get('family_depth', 0)) if pd.notna(row.get('family_depth')) else None, + }) + + # Sort by score descending, then by downloads for tie-breaking + results.sort(key=lambda x: (-x['score'], -x['downloads'])) + + return { + "results": results, + "query": q, + "total_matches": len(matches), + "threshold": threshold + } + + except ImportError: + raise HTTPException(status_code=500, detail="rapidfuzz not installed") + except Exception as e: + logger.exception(f"Fuzzy search error: {e}") + raise HTTPException(status_code=500, detail=f"Search error: {str(e)}") + + @app.get("/api/similar/{model_id}") async def get_similar_models(model_id: str, k: int = Query(10, ge=1, le=50)): """ @@ -1939,6 +2136,168 @@ async def get_model_files(model_id: str, branch: str = Query("main")): ) +# ============================================================================= +# BACKGROUND COMPUTATION ENDPOINTS +# ============================================================================= + +import subprocess +import threading + +# Store for background process +_background_process = None +_background_lock = threading.Lock() + + +class ComputeRequest(BaseModel): + sample_size: Optional[int] = None + all_models: bool = False + + +@app.get("/api/compute/status") +async def get_compute_status(): + """Get the status of background pre-computation.""" + from pathlib import Path + + root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + status_file = Path(root_dir) / "precomputed_data" / "background_status_v1.json" + + if status_file.exists(): + import json + with open(status_file, 'r') as f: + status = json.load(f) + + # Check if process is still running + global _background_process + with _background_lock: + if _background_process is not None: + poll = _background_process.poll() + if poll is None: + status['process_running'] = True + else: + status['process_running'] = False + status['process_exit_code'] = poll + else: + status['process_running'] = False + + return status + + # Check for existing precomputed data + metadata_file = Path(root_dir) / "precomputed_data" / "metadata_v1.json" + models_file = Path(root_dir) / "precomputed_data" / "models_v1.parquet" + + if metadata_file.exists() and models_file.exists(): + import json + with open(metadata_file, 'r') as f: + metadata = json.load(f) + return { + 'status': 'completed', + 'total_models': metadata.get('total_models', 0), + 'created_at': metadata.get('created_at'), + 'process_running': False + } + + return { + 'status': 'not_started', + 'total_models': 0, + 'process_running': False + } + + +@app.post("/api/compute/start") +async def start_background_compute(request: ComputeRequest, background_tasks: BackgroundTasks): + """Start background pre-computation of model embeddings.""" + global _background_process + + with _background_lock: + if _background_process is not None and _background_process.poll() is None: + raise HTTPException( + status_code=409, + detail="Background computation is already running" + ) + + # Prepare command + root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + script_path = os.path.join(root_dir, "backend", "scripts", "precompute_background.py") + venv_python = os.path.join(root_dir, "venv", "bin", "python") + + cmd = [venv_python, script_path] + + if request.all_models: + cmd.append("--all") + elif request.sample_size: + cmd.extend(["--sample-size", str(request.sample_size)]) + else: + cmd.extend(["--sample-size", "150000"]) # Default + + cmd.extend(["--output-dir", os.path.join(root_dir, "precomputed_data")]) + + # Start process in background + log_file = os.path.join(root_dir, "precompute_background.log") + + def run_computation(): + global _background_process + with open(log_file, 'w') as f: + with _background_lock: + _background_process = subprocess.Popen( + cmd, + stdout=f, + stderr=subprocess.STDOUT, + cwd=os.path.join(root_dir, "backend") + ) + _background_process.wait() + + thread = threading.Thread(target=run_computation, daemon=True) + thread.start() + + sample_desc = "all models" if request.all_models else f"{request.sample_size or 150000:,} models" + + return { + "message": f"Background computation started for {sample_desc}", + "status": "starting", + "log_file": log_file + } + + +@app.post("/api/compute/stop") +async def stop_background_compute(): + """Stop the running background computation.""" + global _background_process + + with _background_lock: + if _background_process is None or _background_process.poll() is not None: + return {"message": "No computation is running"} + + _background_process.terminate() + try: + _background_process.wait(timeout=5) + except subprocess.TimeoutExpired: + _background_process.kill() + + return {"message": "Background computation stopped"} + + +@app.get("/api/data/info") +async def get_data_info(): + """Get information about currently loaded data.""" + df = deps.df + + if df is None: + return { + "loaded": False, + "message": "No data loaded" + } + + return { + "loaded": True, + "total_models": len(df), + "columns": list(df.columns), + "unique_libraries": int(df['library_name'].nunique()) if 'library_name' in df.columns else 0, + "unique_pipelines": int(df['pipeline_tag'].nunique()) if 'pipeline_tag' in df.columns else 0, + "has_3d_coords": all(col in df.columns for col in ['x_3d', 'y_3d', 'z_3d']), + "has_2d_coords": all(col in df.columns for col in ['x_2d', 'y_2d']) + } + + if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", 8000)) diff --git a/backend/api/routes/models.py b/backend/api/routes/models.py index 652553fa8ceea430d2f3b2fdeb735197d61d326e..edcb893517dad3d72ed9c448a9796c499cbae0c6 100644 --- a/backend/api/routes/models.py +++ b/backend/api/routes/models.py @@ -13,6 +13,7 @@ from umap import UMAP from models.schemas import ModelPoint from utils.family_tree import calculate_family_depths from utils.dimensionality_reduction import DimensionReducer +from utils.cache import cached_response from core.exceptions import DataNotLoadedError, EmbeddingsNotReadyError import api.dependencies as deps @@ -245,3 +246,64 @@ async def get_models( "returned_count": len(models) } + +@router.get("/family/adoption") +@cached_response(ttl=3600, key_prefix="family_adoption") +async def get_family_adoption( + family: str = Query(..., description="Family name (e.g., 'meta-llama', 'google', 'microsoft')"), + limit: int = Query(100, ge=1, le=1000, description="Maximum number of models to return") +): + """ + Get adoption data for a specific family (S-curve data). + Returns models sorted by creation date with their downloads. + """ + if deps.df is None: + raise DataNotLoadedError() + + df = deps.df + + # Filter by family name (check model_id prefix and tags) + family_lower = family.lower() + filtered_df = df[ + df['model_id'].astype(str).str.lower().str.contains(family_lower, regex=False, na=False) | + df.get('tags', pd.Series([None] * len(df))).astype(str).str.lower().str.contains(family_lower, regex=False, na=False) + ] + + if len(filtered_df) == 0: + return { + "family": family, + "models": [], + "total_models": 0 + } + + # Sort by downloads and limit + filtered_df = filtered_df.nlargest(limit, 'downloads', keep='first') + + # Extract required fields + model_ids = filtered_df['model_id'].astype(str).values + downloads_arr = filtered_df.get('downloads', pd.Series([0] * len(filtered_df))).fillna(0).astype(int).values + created_at_arr = filtered_df.get('createdAt', pd.Series([None] * len(filtered_df))).values + + # Parse dates efficiently + dates = pd.to_datetime(created_at_arr, errors='coerce', utc=True) + + # Build response + adoption_data = [] + for idx in range(len(filtered_df)): + date_val = dates.iloc[idx] if isinstance(dates, pd.Series) else dates[idx] + if pd.notna(date_val): + adoption_data.append({ + "model_id": model_ids[idx], + "downloads": int(downloads_arr[idx]), + "created_at": date_val.isoformat() + }) + + # Sort by date + adoption_data.sort(key=lambda x: x['created_at'] if x['created_at'] else '') + + return { + "family": family, + "models": adoption_data, + "total_models": len(adoption_data) + } + diff --git a/backend/scripts/precompute_background.py b/backend/scripts/precompute_background.py new file mode 100644 index 0000000000000000000000000000000000000000..7dc08503c78899b1f49462e82c458524d3a3cd13 --- /dev/null +++ b/backend/scripts/precompute_background.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +""" +Background pre-computation script for processing ALL models incrementally. +Designed to run in the background and save progress so it can be resumed. + +Features: +- Processes models in batches to manage memory +- Saves progress incrementally +- Can be resumed if interrupted +- Provides status updates via JSON file + +Usage: + # Process all models (default ~500k batch) + python scripts/precompute_background.py --all + + # Process specific number of models + python scripts/precompute_background.py --sample-size 500000 + + # Resume from previous run + python scripts/precompute_background.py --resume + + # Check status + python scripts/precompute_background.py --status +""" + +import argparse +import os +import sys +import json +import time +import logging +import signal +from datetime import datetime +from pathlib import Path +from typing import Optional, Dict, Any +import threading + +import pandas as pd +import numpy as np +from umap import UMAP +from sklearn.decomposition import PCA, IncrementalPCA + +# Add backend to path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from utils.data_loader import ModelDataLoader +from utils.embeddings import ModelEmbedder + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('precompute_background.log') + ] +) +logger = logging.getLogger(__name__) + +# Global flag for graceful shutdown +shutdown_requested = False + +def signal_handler(signum, frame): + global shutdown_requested + logger.warning("Shutdown requested - will save progress and exit...") + shutdown_requested = True + +signal.signal(signal.SIGINT, signal_handler) +signal.signal(signal.SIGTERM, signal_handler) + + +class BackgroundPrecomputer: + """Handles incremental pre-computation of model embeddings and coordinates.""" + + def __init__( + self, + output_dir: str = "precomputed_data", + version: str = "v1", + batch_size: int = 50000, + embedding_batch_size: int = 256 + ): + self.output_dir = Path(output_dir) + self.version = version + self.batch_size = batch_size + self.embedding_batch_size = embedding_batch_size + + # Status file for tracking progress + self.status_file = self.output_dir / f"background_status_{version}.json" + self.output_dir.mkdir(parents=True, exist_ok=True) + + # Initialize components + self.data_loader = ModelDataLoader() + self.embedder = ModelEmbedder() + + def get_status(self) -> Dict[str, Any]: + """Get current computation status.""" + if self.status_file.exists(): + with open(self.status_file, 'r') as f: + return json.load(f) + return { + 'status': 'not_started', + 'total_models': 0, + 'processed_models': 0, + 'current_batch': 0, + 'started_at': None, + 'last_updated': None, + 'error': None + } + + def save_status(self, status: Dict[str, Any]): + """Save computation status.""" + status['last_updated'] = datetime.now().isoformat() + with open(self.status_file, 'w') as f: + json.dump(status, f, indent=2) + + def load_full_dataset(self) -> pd.DataFrame: + """Load the full dataset without sampling.""" + logger.info("Loading full dataset from HuggingFace...") + from datasets import load_dataset + dataset = load_dataset("modelbiome/ai_ecosystem", split="train") + df = dataset.to_pandas() + logger.info(f"Loaded {len(df):,} total models") + return df + + def precompute_all( + self, + sample_size: Optional[int] = None, + resume: bool = False, + pca_dims: int = 50 + ): + """ + Pre-compute embeddings and coordinates for all or specified number of models. + + Args: + sample_size: If None, process all models. Otherwise, process this many. + resume: If True, resume from previous progress + pca_dims: Number of PCA dimensions for pre-reduction + """ + global shutdown_requested + + start_time = time.time() + + # Get current status + status = self.get_status() if resume else { + 'status': 'initializing', + 'total_models': 0, + 'processed_models': 0, + 'current_batch': 0, + 'started_at': datetime.now().isoformat(), + 'error': None, + 'batches_completed': [] + } + + try: + # Step 1: Load data + status['status'] = 'loading_data' + self.save_status(status) + + if sample_size: + logger.info(f"Loading {sample_size:,} models with stratified sampling...") + df = self.data_loader.load_data(sample_size=sample_size, prioritize_base_models=True) + else: + logger.info("Loading ALL models...") + df = self.load_full_dataset() + + total_models = len(df) + status['total_models'] = total_models + logger.info(f"Total models to process: {total_models:,}") + + # Build combined text + logger.info("Building combined text for embeddings...") + df['combined_text'] = ( + df.get('tags', '').astype(str) + ' ' + + df.get('pipeline_tag', '').astype(str) + ' ' + + df.get('library_name', '').astype(str) + ) + if 'modelCard' in df.columns: + df['combined_text'] = df['combined_text'] + ' ' + df['modelCard'].astype(str).str[:500] + + # Step 2: Generate embeddings in batches + status['status'] = 'generating_embeddings' + self.save_status(status) + + logger.info("Generating embeddings...") + all_embeddings = [] + texts = df['combined_text'].tolist() + + num_batches = (len(texts) + self.batch_size - 1) // self.batch_size + + for batch_idx in range(num_batches): + if shutdown_requested: + logger.warning("Shutdown requested - saving partial progress...") + break + + batch_start = batch_idx * self.batch_size + batch_end = min(batch_start + self.batch_size, len(texts)) + batch_texts = texts[batch_start:batch_end] + + logger.info(f"Processing embedding batch {batch_idx + 1}/{num_batches} " + f"(models {batch_start:,} - {batch_end:,})...") + + batch_embeddings = self.embedder.generate_embeddings( + batch_texts, + batch_size=self.embedding_batch_size + ) + all_embeddings.append(batch_embeddings) + + status['processed_models'] = batch_end + status['current_batch'] = batch_idx + 1 + status['progress_percent'] = round(100 * batch_end / total_models, 1) + self.save_status(status) + + if shutdown_requested: + status['status'] = 'interrupted' + self.save_status(status) + return + + embeddings = np.vstack(all_embeddings) + logger.info(f"Generated embeddings: {embeddings.shape}") + + # Step 3: PCA pre-reduction + status['status'] = 'pca_reduction' + self.save_status(status) + + logger.info(f"Applying PCA reduction ({embeddings.shape[1]} -> {pca_dims} dims)...") + pca = PCA(n_components=pca_dims, random_state=42) + embeddings_reduced = pca.fit_transform(embeddings) + explained_var = pca.explained_variance_ratio_.sum() + logger.info(f"PCA complete (preserved {explained_var:.1%} variance)") + + if shutdown_requested: + status['status'] = 'interrupted' + self.save_status(status) + return + + # Step 4: UMAP 3D + status['status'] = 'umap_3d' + self.save_status(status) + + logger.info("Running UMAP for 3D coordinates...") + reducer_3d = UMAP( + n_components=3, + n_neighbors=15, + min_dist=0.1, + metric='euclidean', + n_jobs=-1, + low_memory=True if total_models > 200000 else False, + spread=1.5, + verbose=True + ) + coords_3d = reducer_3d.fit_transform(embeddings_reduced) + logger.info(f"3D coordinates: {coords_3d.shape}") + + if shutdown_requested: + status['status'] = 'interrupted' + self.save_status(status) + return + + # Step 5: UMAP 2D + status['status'] = 'umap_2d' + self.save_status(status) + + logger.info("Running UMAP for 2D coordinates...") + reducer_2d = UMAP( + n_components=2, + n_neighbors=15, + min_dist=0.1, + metric='euclidean', + n_jobs=-1, + low_memory=True if total_models > 200000 else False, + spread=1.5, + verbose=True + ) + coords_2d = reducer_2d.fit_transform(embeddings_reduced) + logger.info(f"2D coordinates: {coords_2d.shape}") + + # Step 6: Save results + status['status'] = 'saving' + self.save_status(status) + + logger.info("Saving results...") + + # Prepare output DataFrame + output_df = df.copy() + output_df['x_3d'] = coords_3d[:, 0] + output_df['y_3d'] = coords_3d[:, 1] + output_df['z_3d'] = coords_3d[:, 2] + output_df['x_2d'] = coords_2d[:, 0] + output_df['y_2d'] = coords_2d[:, 1] + + # Save models + models_file = self.output_dir / f"models_{self.version}.parquet" + output_df.to_parquet(models_file, compression='snappy', index=False) + logger.info(f"Saved: {models_file} ({models_file.stat().st_size / 1024 / 1024:.1f} MB)") + + # Save embeddings + embeddings_file = self.output_dir / f"embeddings_{self.version}.parquet" + embeddings_df = pd.DataFrame({ + 'model_id': df['modelId'].values, + 'embedding': [emb.tolist() for emb in embeddings] + }) + embeddings_df.to_parquet(embeddings_file, compression='snappy', index=False) + logger.info(f"Saved: {embeddings_file} ({embeddings_file.stat().st_size / 1024 / 1024:.1f} MB)") + + # Save metadata + total_time = time.time() - start_time + metadata = { + 'version': self.version, + 'created_at': datetime.now().isoformat(), + 'total_models': int(total_models), + 'embedding_dim': int(embeddings.shape[1]), + 'umap_3d_shape': list(coords_3d.shape), + 'umap_2d_shape': list(coords_2d.shape), + 'unique_libraries': int(df['library_name'].nunique()), + 'unique_pipelines': int(df['pipeline_tag'].nunique()), + 'processing_time_seconds': total_time, + 'processing_time_hours': total_time / 3600, + 'pca_dims': pca_dims, + 'pca_variance_preserved': float(explained_var) + } + + metadata_file = self.output_dir / f"metadata_{self.version}.json" + with open(metadata_file, 'w') as f: + json.dump(metadata, f, indent=2) + logger.info(f"Saved: {metadata_file}") + + # Update final status + status['status'] = 'completed' + status['completed_at'] = datetime.now().isoformat() + status['processing_time_hours'] = round(total_time / 3600, 2) + self.save_status(status) + + logger.info("="*60) + logger.info("BACKGROUND PRE-COMPUTATION COMPLETE!") + logger.info("="*60) + logger.info(f"Total time: {total_time/3600:.2f} hours ({total_time/60:.1f} minutes)") + logger.info(f"Models processed: {total_models:,}") + logger.info(f"Output directory: {self.output_dir}") + logger.info("="*60) + + except Exception as e: + logger.error(f"Pre-computation failed: {e}", exc_info=True) + status['status'] = 'failed' + status['error'] = str(e) + self.save_status(status) + raise + + +def main(): + parser = argparse.ArgumentParser( + description="Background pre-computation of HF model embeddings and coordinates" + ) + parser.add_argument( + "--sample-size", type=int, default=None, + help="Number of models to process (default: all)" + ) + parser.add_argument( + "--all", action="store_true", + help="Process ALL models (may take many hours)" + ) + parser.add_argument( + "--resume", action="store_true", + help="Resume from previous progress" + ) + parser.add_argument( + "--status", action="store_true", + help="Show current computation status and exit" + ) + parser.add_argument( + "--output-dir", type=str, default="../precomputed_data", + help="Output directory" + ) + parser.add_argument( + "--version", type=str, default="v1", + help="Version tag" + ) + parser.add_argument( + "--batch-size", type=int, default=50000, + help="Batch size for processing" + ) + + args = parser.parse_args() + + precomputer = BackgroundPrecomputer( + output_dir=args.output_dir, + version=args.version, + batch_size=args.batch_size + ) + + if args.status: + status = precomputer.get_status() + print(json.dumps(status, indent=2)) + return + + sample_size = None if args.all else (args.sample_size or 150000) + + if sample_size: + logger.info(f"Processing {sample_size:,} models...") + else: + logger.info("Processing ALL models (this may take many hours)...") + + try: + precomputer.precompute_all( + sample_size=sample_size, + resume=args.resume + ) + except Exception as e: + logger.error(f"Failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5f29706004b9a2cd208835b7ae7f15aaaaf22f21..6221cdedacbb4e6491aa5a95956c43fc60650472 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,7 +29,10 @@ "axios": "^1.6.0", "comlink": "^4.4.1", "d3": "^7.8.5", + "d3-array": "^3.2.4", + "fuse.js": "^7.1.0", "idb": "^8.0.0", + "lucide-react": "^0.555.0", "msgpack-lite": "^0.1.26", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -5328,6 +5331,18 @@ "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", "license": "MIT" }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@visx/visx": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/@visx/visx/-/visx-3.12.0.tgz", @@ -7853,9 +7868,9 @@ } }, "node_modules/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -10484,6 +10499,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -13364,6 +13388,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", + "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/maath": { "version": "0.10.8", "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index b4a2da6c8740ab45604e92c0929b332046117c73..f47f78f9c962f6bb4bfa71f5d105e75ca93c208b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,10 @@ "axios": "^1.6.0", "comlink": "^4.4.1", "d3": "^7.8.5", + "d3-array": "^3.2.4", + "fuse.js": "^7.1.0", "idb": "^8.0.0", + "lucide-react": "^0.555.0", "msgpack-lite": "^0.1.26", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/public/index.html b/frontend/public/index.html index 544264c61c2f3f7008a29c0f75f87d44d36efde7..bee455b624fa8ea6f1fe033e2eece8816847fb65 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -4,6 +4,10 @@ + + + + div:last-child { + padding: 1.5rem; + overflow-y: auto; + } + + .control-search { + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid var(--border-medium); + padding: 0.35rem 0.75rem; + background: var(--bg-primary); + transition: all var(--transition-base); + position: relative; + width: 100%; + } + + .control-search:focus-within { + border-color: var(--accent-blue); + box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1); + } + + .search-icon { + font-size: 0.9rem; + color: var(--text-secondary); + } + + .control-search-input { + border: none; + background: transparent; + color: var(--text-primary); + font-size: 0.85rem; + font-family: var(--font-primary); + width: 150px; + min-width: 100px; + max-width: 200px; + outline: none; + } + + .control-search-input:focus { + outline: none; + } + + .control-search-input::placeholder { + color: var(--text-tertiary); + } + + /* Legacy filters-top-bar for backwards compatibility */ + .filters-top-bar { + background: var(--bg-primary); + border-bottom: 1px solid var(--border-light); + box-shadow: var(--shadow-sm); + z-index: 50; + position: sticky; + top: 0; + } + + .filters-bar-content { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + flex-wrap: wrap; + max-width: 100%; + box-sizing: border-box; + } + + .filter-item { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + } + + .filter-item-range { + gap: 0.5rem; + min-width: 140px; + } + + .filter-item-count { + margin-left: auto; + gap: 0.25rem; + } + + .filter-item-averages { + gap: 1rem; + margin-left: 0.5rem; + padding-left: 1rem; + border-left: 1px solid var(--border-light); + } + + .average-item { + display: flex; + flex-direction: column; + gap: 0.15rem; + align-items: flex-end; + } + + .average-label { + font-size: 0.7rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .average-value { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + } + + .filter-item-actions { + gap: 0.5rem; + margin-left: 0.5rem; + } + + .filter-action-btn { + padding: 0.35rem 0.6rem; + border: 1px solid var(--border-medium); + border-radius: 0; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.8rem; + font-family: var(--font-primary); + cursor: pointer; + transition: all var(--transition-base); + white-space: nowrap; + } + + .filter-action-btn:hover { + background: var(--bg-secondary); + border-color: var(--accent-blue); + color: var(--accent-blue); + } + + .filter-refresh-btn { + padding: 0.35rem 0.5rem; + font-size: 0.9rem; + } + + .filter-group { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + } + + .filter-group:has(.filter-label-compact) { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .filter-input { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-medium); + border-radius: 0; + font-size: 0.875rem; + font-family: var(--font-primary); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 180px; + max-width: 100%; + transition: all var(--transition-base); + } + + .filter-input-compact { + padding: 0.4rem 0.6rem; + border: 1px solid var(--border-medium); + border-radius: 0; + font-size: 0.8rem; + font-family: var(--font-primary); + background: var(--bg-primary); + color: var(--text-primary); + width: 150px; + transition: all var(--transition-base); + } + + .filter-input-compact:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); + } + + .filter-input:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); + } + + .filter-label-inline { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + white-space: nowrap; + } + + .filter-label-inline span:first-child { + min-width: 70px; + font-weight: 500; + } + + .filter-label-text { + min-width: 90px; + font-weight: 500; + color: var(--text-secondary); + font-size: 0.875rem; + } + + .filter-label-compact { + display: block; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .filter-range { + width: 100px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-tertiary); + border-radius: 0; + outline: none; + cursor: pointer; + flex-shrink: 1; + } + + .filter-range-compact { + width: 80px; + height: 3px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-tertiary); + border-radius: 0; + outline: none; + cursor: pointer; + flex-shrink: 1; + } + + .filter-range-compact::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 0; + background: var(--accent-blue); + cursor: pointer; + } + + .filter-range-compact::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 0; + background: var(--accent-blue); + cursor: pointer; + border: none; + } + + .filter-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 0; + background: var(--accent-blue); + cursor: pointer; + } + + .filter-range::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 0; + background: var(--accent-blue); + cursor: pointer; + border: none; + } + + .filter-value-inline { + min-width: 50px; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + text-align: right; + flex-shrink: 0; + } + + .filter-select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-medium); + border-radius: 0; + font-size: 0.875rem; + font-family: var(--font-primary); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + transition: all var(--transition-base); + min-width: 110px; + max-width: 100%; + } + + .filter-select-compact { + padding: 0.4rem 0.6rem; + border: 1px solid var(--border-medium); + border-radius: 0; + font-size: 0.8rem; + font-family: var(--font-primary); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + transition: all var(--transition-base); + min-width: 90px; + max-width: 100%; + } + + .filter-select-compact:hover { + border-color: var(--border-dark); + } + + .filter-select-compact:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); + } + + .filter-select:hover { + border-color: var(--border-dark); + } + + .filter-select:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); + } + + .filter-checkbox-inline { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-primary); + cursor: pointer; + } + + .filter-checkbox-compact { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--text-primary); + cursor: pointer; + white-space: nowrap; } - - .App-header h1 { + + .filter-checkbox-compact input[type="checkbox"] { margin: 0; - font-size: 1.5rem; - font-weight: 600; - letter-spacing: -0.01em; - line-height: 1.3; - color: #ffffff; + cursor: pointer; } - - .App-header a { - color: #64b5f6; - text-decoration: none; + + .filter-item-label { + font-size: 0.75rem; font-weight: 500; - transition: all var(--transition-base); + color: var(--text-secondary); + white-space: nowrap; + min-width: 60px; } - - .App-header a:hover { - color: #90caf9; + + .filter-item-value { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-primary); + min-width: 45px; + text-align: right; + white-space: nowrap; } - - .stats { - display: flex; - gap: 0.75rem; + + .model-count-compact { font-size: 0.85rem; - flex-wrap: wrap; + font-weight: 600; + color: var(--text-primary); } - - .stats span { - padding: 0.5rem 0.875rem; - background: rgba(255, 255, 255, 0.1); - border-radius: 0; - border: 1px solid rgba(255, 255, 255, 0.2); + + .model-count-compact-separator { + font-size: 0.75rem; + color: var(--text-tertiary); + margin: 0 0.15rem; + } + + .model-count-compact-secondary { + font-size: 0.8rem; font-weight: 500; - transition: all var(--transition-base); + color: var(--text-secondary); } - - .stats span:hover { - background: rgba(255, 255, 255, 0.15); + + .filter-checkbox-inline input[type="checkbox"] { + margin: 0; + cursor: pointer; } - - /* ============================================ - LAYOUT - ============================================ */ - .main-content { + + .model-count-inline { + margin-left: auto; display: flex; - height: calc(100vh - 80px); + align-items: baseline; + gap: 0.25rem; + font-size: 0.875rem; } - - .sidebar { - width: 340px; - padding: 1.5rem; - background: var(--bg-secondary); - overflow-y: auto; - border-right: 1px solid var(--border-light); + + .model-count-inline .model-count-value { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + } + + .model-count-inline .model-count-label { + font-size: 0.75rem; + color: var(--text-secondary); } .visualization { flex: 1; - padding: 1.5rem; + padding: 0; display: flex; align-items: center; justify-content: center; background: var(--bg-primary); - overflow: auto; + overflow: hidden; position: relative; + width: 100%; + height: 100%; } /* ============================================ SIDEBAR COMPONENTS ============================================ */ + .sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-light); + } + .sidebar h2 { - margin-top: 0; - font-size: 1.25rem; + margin: 0; + font-size: 1.5rem; font-weight: 600; letter-spacing: -0.01em; color: var(--text-primary); } .sidebar h3 { - font-size: 0.9rem; + font-size: 1rem; font-weight: 600; margin: 0 0 0.875rem 0; letter-spacing: -0.01em; color: var(--text-primary); + line-height: 1.3; } .sidebar-section { background: var(--bg-elevated); border-radius: 0; padding: 1.25rem; - margin-bottom: 1rem; + margin-bottom: 1.25rem; border: 1px solid var(--border-light); transition: all var(--transition-base); } @@ -179,6 +1362,79 @@ border-color: var(--border-medium); box-shadow: var(--shadow-sm); } + + .sidebar-section:last-child { + margin-bottom: 0; + } + + .filter-badge { + font-size: 0.75rem; + background: var(--accent-primary); + color: white; + padding: 0.35rem 0.7rem; + border-radius: 0; + font-weight: 600; + } + + .model-count-display { + background: var(--bg-tertiary); + border: 1px solid var(--border-medium); + font-size: 0.9rem; + margin-bottom: 1.5rem; + padding: 1rem; + } + + .model-count-main { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + } + + .model-count-value { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + } + + .model-count-label { + margin-left: 0.4rem; + color: var(--text-secondary); + } + + .model-count-meta { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.25rem; + } + + .model-count-meta:last-child { + font-size: 0.75rem; + } + + .filter-label-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + } + + .filter-label { + font-weight: 500; + color: var(--text-primary); + } + + .filter-value { + font-weight: 600; + color: var(--accent-primary); + } + + .filter-range-labels { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: var(--text-secondary); + margin-top: 0.25rem; + } /* ============================================ FORM ELEMENTS @@ -248,11 +1504,10 @@ transition: all var(--transition-base); } - .sidebar input[type="range"]::-webkit-slider-thumb:hover { - background: var(--accent-hover); - transform: scale(1.15); - box-shadow: var(--shadow-lg); - } +.sidebar input[type="range"]::-webkit-slider-thumb:hover { + background: var(--accent-hover); + box-shadow: var(--shadow-lg); +} .sidebar input[type="range"]::-moz-range-thumb { width: 18px; @@ -265,11 +1520,10 @@ transition: all var(--transition-base); } - .sidebar input[type="range"]::-moz-range-thumb:hover { - background: var(--accent-hover); - transform: scale(1.15); - box-shadow: var(--shadow-lg); - } +.sidebar input[type="range"]::-moz-range-thumb:hover { + background: var(--accent-hover); + box-shadow: var(--shadow-lg); +} /* Checkbox */ .sidebar input[type="checkbox"] { @@ -279,6 +1533,83 @@ accent-color: var(--accent-primary); margin-right: 0.5rem; } + + /* Form Groups */ + .form-group { + margin-bottom: 1.25rem; + } + + .form-group:last-child { + margin-bottom: 0; + } + + .form-label { + display: block; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; + letter-spacing: -0.01em; + } + + .form-select { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid var(--border-light); + border-radius: 0; + font-size: 0.9rem; + font-family: var(--font-primary); + background: var(--bg-primary); + color: var(--text-primary); + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); + cursor: pointer; + } + + .form-select:hover { + border-color: var(--border-medium); + box-shadow: var(--shadow-md); + background: var(--bg-secondary); + } + + .form-select:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + background: var(--bg-primary); + } + + /* Checkbox Labels */ + .checkbox-label { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + margin-bottom: 0.75rem; + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: 0; + cursor: pointer; + transition: all var(--transition-base); + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary); + } + + .checkbox-label:hover { + background: var(--bg-tertiary); + border-color: var(--border-medium); + } + + .checkbox-label input[type="checkbox"] { + margin: 0; + cursor: pointer; + } + + .checkbox-label span { + flex: 1; + user-select: none; + } /* ============================================ BUTTONS @@ -304,11 +1635,10 @@ box-shadow: var(--shadow-md); } - .btn-primary:hover:not(:disabled) { - background: var(--accent-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-lg); - } +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: var(--shadow-lg); +} .btn-secondary { background: var(--bg-tertiary); @@ -343,26 +1673,26 @@ align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; - background: #e3f2fd; - color: #1976d2; + background: var(--bg-tertiary, #f5f5f5); + color: var(--text-primary, #1a1a1a); border-radius: 0; font-size: 0.8rem; font-weight: 500; - border: 1px solid #90caf9; + border: 1px solid var(--border-medium, #d0d0d0); cursor: pointer; transition: all var(--transition-base); } - .filter-chip:hover { - background: #bbdefb; - transform: translateY(-1px); - box-shadow: var(--shadow-sm); - } +.filter-chip:hover { + background: var(--bg-secondary, #fafafa); + box-shadow: var(--shadow-sm); + border-color: var(--accent-blue, #4a90e2); +} .filter-chip.active { - background: #1976d2; - color: white; - border-color: #1976d2; + background: var(--accent-blue, #4a90e2); + color: #ffffff; + border-color: var(--accent-blue, #4a90e2); } .filter-chip .remove { @@ -546,10 +1876,14 @@ position: absolute; cursor: pointer; inset: 0; - background-color: #ccc; + background-color: var(--border-medium, #d0d0d0); border-radius: 0; transition: background-color var(--transition-slow); } + + [data-theme="dark"] .label-toggle-slider { + background-color: #555; + } .label-toggle-slider:before { position: absolute; @@ -586,10 +1920,10 @@ transition: all var(--transition-base); } - .theme-toggle:hover { - background: var(--bg-tertiary); - transform: scale(1.05); - } +.theme-toggle:hover { + background: var(--bg-tertiary); + border-color: var(--accent-blue); +} /* ============================================ ZOOM CONTROLS @@ -634,10 +1968,9 @@ transition: all var(--transition-base); } - .zoom-slider::-webkit-slider-thumb:hover { - transform: scale(1.2); - box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2); - } +.zoom-slider::-webkit-slider-thumb:hover { + box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2); +} .zoom-slider::-moz-range-thumb { width: 16px; @@ -649,10 +1982,9 @@ transition: all var(--transition-base); } - .zoom-slider::-moz-range-thumb:hover { - transform: scale(1.2); - box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2); - } +.zoom-slider::-moz-range-thumb:hover { + box-shadow: 0 0 0 4px rgba(74, 144, 226, 0.2); +} .zoom-slider:disabled { opacity: 0.5; @@ -699,11 +2031,11 @@ .loading::after { content: ''; - width: 40px; - height: 40px; - border: 4px solid var(--border-light); + width: 16px; + height: 16px; + border: 2px solid var(--border-light); border-top-color: var(--accent-primary); - border-radius: 0; + border-radius: 50%; animation: spin 0.8s linear infinite; } @@ -719,6 +2051,12 @@ border: 1px solid #ffcdd2; font-weight: 500; } + + [data-theme="dark"] .error { + color: #ff6b6b; + background: rgba(211, 47, 47, 0.2); + border: 1px solid rgba(211, 47, 47, 0.4); + } .empty { color: var(--text-secondary); @@ -767,4 +2105,215 @@ .visualization svg { display: block; background: var(--bg-primary); + } + + /* ============================================ + RESPONSIVE DESIGN + ============================================ */ + @media (max-width: 1024px) { + .nav-sidebar { + width: 200px; + } + + .nav-sidebar.collapsed { + width: 50px; + } + + .app-main { + margin-left: 200px; + width: calc(100% - 200px); + } + + .nav-sidebar.collapsed ~ .app-main { + margin-left: 50px; + width: calc(100% - 50px); + } + + .filters-bar-content { + gap: 1rem; + padding: 0.75rem 1rem; + } + + .filter-section { + gap: 0.75rem; + padding-right: 1rem; + } + + .sidebar { + width: 300px; + padding: 1rem; + } + + .header-content { + flex-direction: column; + align-items: flex-start; + } + + .header-right { + width: 100%; + justify-content: flex-start; + margin-top: 0.5rem; + } + } + + @media (max-width: 768px) { + .nav-sidebar { + width: 60px; + } + + .nav-sidebar .nav-sidebar-header h1, + .nav-sidebar .nav-subtitle { + display: none; + } + + .app-main { + margin-left: 60px; + width: calc(100% - 60px); + } + + .control-bar-content { + flex-wrap: wrap; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + } + + .control-bar-center { + order: 2; + width: 100%; + justify-content: flex-start; + gap: 0.75rem; + } + + .control-bar-right { + order: 1; + width: 100%; + justify-content: flex-start; + } + + .control-search { + width: 100%; + } + + .control-search-input { + width: 100%; + max-width: none; + } + + .filters-dropdown { + left: auto; + right: 0; + min-width: 200px; + } + + .control-select { + min-width: 110px; + } + + .App-header { + padding: 1rem; + } + + .App-header h1 { + font-size: 1.25rem; + } + + .nav-tab { + padding: 0.4rem 0.75rem; + font-size: 0.85rem; + } + + .sidebar { + width: 100%; + max-width: 100%; + border-right: none; + border-bottom: 1px solid var(--border-light); + } + + .main-content { + flex-direction: column; + height: auto; + } + + .visualization { + min-height: 500px; + } + + .nav-sidebar { + width: 200px; + } + + .app-main { + margin-left: 200px; + } + + .nav-sidebar-header h1 { + font-size: 1.25rem; + } + + .nav-tab { + font-size: 0.85rem; + padding: 0.6rem 1rem; + } + } + + @media (max-width: 480px) { + .nav-sidebar { + width: 180px; + } + + .app-main { + margin-left: 180px; + width: calc(100% - 180px); + } + + .nav-sidebar-header { + padding: 1.5rem 1rem 1rem; + } + + .nav-tabs { + padding: 0.75rem 0; + } + + .nav-tab { + font-size: 0.8rem; + padding: 0.5rem 1rem; + } + + .filters-top-bar { + padding: 0.5rem; + } + + .filters-bar-content { + gap: 0.5rem; + flex-wrap: wrap; + } + + .filter-input { + min-width: 120px; + font-size: 0.75rem; + padding: 0.4rem 0.5rem; + } + + .filter-select { + min-width: 80px; + font-size: 0.75rem; + padding: 0.35rem 0.4rem; + } + + .filter-range { + width: 60px; + } + + .filter-label-inline { + font-size: 0.75rem; + } + + .filter-label-inline span:first-child { + min-width: 50px; + } + + .filter-value-inline { + min-width: 40px; + font-size: 0.75rem; + } } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c268adaf4abe254600040f709c60b1c913da29c5..06ba5f597efb677c1f6601d5713c91480c478af4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,37 +1,38 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -// Visualizations -import ScatterPlot from './components/visualizations/ScatterPlot'; +import { ChevronLeft, ChevronRight, Palette, Maximize2, Eye } from 'lucide-react'; +import IntroModal from './components/ui/IntroModal'; import ScatterPlot3D from './components/visualizations/ScatterPlot3D'; import NetworkGraph from './components/visualizations/NetworkGraph'; import DistributionView from './components/visualizations/DistributionView'; -// Controls -import RandomModelButton from './components/controls/RandomModelButton'; -import ZoomSlider from './components/controls/ZoomSlider'; -import ThemeToggle from './components/controls/ThemeToggle'; -// import RenderingStyleSelector from './components/controls/RenderingStyleSelector'; -// import VisualizationModeButtons from './components/controls/VisualizationModeButtons'; -// import ClusterFilter from './components/controls/ClusterFilter'; import type { Cluster } from './components/controls/ClusterFilter'; -import NodeDensitySlider from './components/controls/NodeDensitySlider'; -// Modals -import ModelModal from './components/modals/ModelModal'; -// UI Components -import LiveModelCount from './components/ui/LiveModelCount'; -// import ModelTooltip from './components/ui/ModelTooltip'; import ErrorBoundary from './components/ui/ErrorBoundary'; -import VirtualSearchResults from './components/ui/VirtualSearchResults'; +import LiveModelCounter from './components/ui/LiveModelCounter'; +import ModelPopup from './components/ui/ModelPopup'; +import AnalyticsPage from './pages/AnalyticsPage'; +import FamiliesPage from './pages/FamiliesPage'; // Types & Utils -import { ModelPoint, Stats, FamilyTree, SearchResult, SimilarModel } from './types'; +import { ModelPoint, Stats, SearchResult } from './types'; +import IntegratedSearch from './components/controls/IntegratedSearch'; import cache, { IndexedDBCache } from './utils/data/indexedDB'; import { debounce } from './utils/debounce'; import requestManager from './utils/api/requestManager'; import { fetchWithMsgPack, decodeModelsMsgPack } from './utils/api/msgpackDecoder'; -import { useFilterStore, ViewMode, ColorByOption, SizeByOption } from './stores/filterStore'; +import { useFilterStore, ViewMode, ColorByOption, SizeByOption, FilterState } from './stores/filterStore'; import { API_BASE } from './config/api'; import './App.css'; const logger = { error: (message: string, error?: unknown) => { + // Suppress NetworkError messages - they're expected during backend startup + if (error instanceof Error) { + const errorMsg = error.message.toLowerCase(); + if (errorMsg.includes('networkerror') || + errorMsg.includes('failed to fetch') || + errorMsg.includes('network request failed')) { + // Silently ignore network errors during startup + return; + } + } if (process.env.NODE_ENV === 'development') { console.error(message, error); } @@ -39,16 +40,11 @@ const logger = { }; function App() { - // Filter store state const { viewMode, colorBy, sizeBy, colorScheme, - showLabels, - zoomLevel, - // nodeDensity, - // renderingStyle, theme, selectedClusters, searchQuery, @@ -58,16 +54,7 @@ function App() { setColorBy, setSizeBy, setColorScheme, - setShowLabels, - setZoomLevel, - // setNodeDensity, - // setRenderingStyle, - // setSelectedClusters, setSearchQuery, - setMinDownloads, - setMinLikes, - getActiveFilterCount, - resetFilters: resetFilterStore, } = useFilterStore(); // Initialize theme on mount @@ -76,65 +63,67 @@ function App() { }, [theme]); const [data, setData] = useState([]); - const [filteredCount, setFilteredCount] = useState(null); - const [stats, setStats] = useState(null); + const [, setFilteredCount] = useState(null); + const [, setReturnedCount] = useState(null); + const [, setStats] = useState(null); const [loading, setLoading] = useState(true); + const [, setLoadingMessage] = useState('Loading models...'); + const [, setLoadingProgress] = useState(undefined); const [error, setError] = useState(null); const [selectedModel, setSelectedModel] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [selectedModels, setSelectedModels] = useState([]); - const [baseModelsOnly, setBaseModelsOnly] = useState(false); - const [semanticSimilarityMode, setSemanticSimilarityMode] = useState(false); - const [semanticQueryModel, setSemanticQueryModel] = useState(null); + const [showIntro, setShowIntro] = useState(() => { + // Check if user has dismissed the intro before + return localStorage.getItem('hf-intro-dismissed') !== 'true'; + }); + const [baseModelsOnly] = useState(false); + const [navCollapsed, setNavCollapsed] = useState(false); + const [semanticSimilarityMode] = useState(false); + const [semanticQueryModel] = useState(null); + const [showAnalytics, setShowAnalytics] = useState(false); + const [showFamilies, setShowFamilies] = useState(false); - const [familyTree, setFamilyTree] = useState([]); - const [familyTreeModelId, setFamilyTreeModelId] = useState(null); - const [searchResults, setSearchResults] = useState([]); - const [searchInput, setSearchInput] = useState(''); - const [showSearchResults, setShowSearchResults] = useState(false); - // const [viewCenter, setViewCenter] = useState<{ x: number; y: number; z: number } | null>(null); - const [projectionMethod, setProjectionMethod] = useState<'umap' | 'tsne'>('umap'); + const [, setSearchResults] = useState([]); + const [searchInput] = useState(''); + const [, setShowSearchResults] = useState(false); + const [projectionMethod] = useState<'umap' | 'tsne'>('umap'); const [bookmarkedModels, setBookmarkedModels] = useState([]); - const [comparisonModels, setComparisonModels] = useState([]); - const [similarModels, setSimilarModels] = useState([]); - const [showSimilar, setShowSimilar] = useState(false); - const [showLegend, setShowLegend] = useState(true); - const [hoveredModel, setHoveredModel] = useState(null); - const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null); + const [, setHoveredModel] = useState(null); + const [, setTooltipPosition] = useState<{ x: number; y: number } | null>(null); + const [, setLiveModelCount] = useState(null); - // Structural visualization options - const [showNetworkEdges, setShowNetworkEdges] = useState(false); - const [showStructuralGroups, setShowStructuralGroups] = useState(false); - const [overviewMode, setOverviewMode] = useState(false); - const [networkEdgeType, setNetworkEdgeType] = useState<'library' | 'pipeline' | 'combined'>('combined'); - const [maxHierarchyDepth, setMaxHierarchyDepth] = useState(null); - const [showDistanceHeatmap, setShowDistanceHeatmap] = useState(false); - const [highlightedPath, setHighlightedPath] = useState([]); - const [useGraphEmbeddings, setUseGraphEmbeddings] = useState(false); - const [embeddingType, setEmbeddingType] = useState('text-only'); - const [clusters, setClusters] = useState([]); - const [clustersLoading, setClustersLoading] = useState(false); + const [useGraphEmbeddings] = useState(false); + const [, setEmbeddingType] = useState('text-only'); + const [, setClusters] = useState([]); + const [, setClustersLoading] = useState(false); - const activeFilterCount = getActiveFilterCount(); - - const resetFilters = useCallback(() => { - resetFilterStore(); - setMinDownloads(0); - setMinLikes(0); - setSearchQuery(''); - }, [resetFilterStore, setMinDownloads, setMinLikes, setSearchQuery]); - const [width, setWidth] = useState(window.innerWidth * 0.7); - const [height, setHeight] = useState(window.innerHeight * 0.7); + const [width, setWidth] = useState(window.innerWidth - 240); // Account for left sidebar + const [height, setHeight] = useState(window.innerHeight - 160); // Account for header and top bar useEffect(() => { const handleResize = () => { - setWidth(window.innerWidth * 0.7); - setHeight(window.innerHeight * 0.7); + setWidth(window.innerWidth - 240); // Account for left sidebar (240px) + setHeight(window.innerHeight - 160); // Account for header (~80px) and top bar (~80px) }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); + + // Stable callbacks for ScatterPlot3D to prevent re-renders + const handlePointClick = useCallback((model: ModelPoint) => { + setSelectedModel(model); + setIsModalOpen(true); + }, []); + + const handleHover = useCallback((model: ModelPoint | null, position?: { x: number; y: number }) => { + setHoveredModel(model); + if (model && position) { + setTooltipPosition(position); + } else { + setTooltipPosition(null); + } + }, []); const fetchDataAbortRef = useRef<(() => void) | null>(null); @@ -159,16 +148,16 @@ function App() { setData(cachedModels); setFilteredCount(cachedModels.length); setLoading(false); - // Fetch in background to update cache if stale - setTimeout(() => { - fetchData(); - }, 100); + // Don't recursively call fetchData - it causes infinite loops + // The cache is valid, use it return; } let models: ModelPoint[]; let count: number | null = null; if (semanticSimilarityMode && semanticQueryModel) { + setLoadingMessage('Finding similar models...'); + setLoadingProgress(50); const params = new URLSearchParams({ query_model_id: semanticQueryModel, k: '500', @@ -191,6 +180,8 @@ function App() { count = models.length; } } else { + setLoadingMessage('Loading embeddings and coordinates...'); + setLoadingProgress(40); const params = new URLSearchParams({ min_downloads: minDownloads.toString(), min_likes: minLikes.toString(), @@ -204,7 +195,8 @@ function App() { params.append('search_query', searchQuery); } - params.append('max_points', viewMode === '3d' ? '50000' : viewMode === 'scatter' ? '10000' : viewMode === 'network' ? '500' : '5000'); + // Request up to 150k models for scatter plots, limit network graph for performance + params.append('max_points', viewMode === 'network' ? '500' : '150000'); // Add format parameter for MessagePack support params.append('format', 'msgpack'); @@ -236,6 +228,7 @@ function App() { } else { models = result.models || []; count = result.filtered_count ?? models.length; + setReturnedCount(result.returned_count ?? models.length); setEmbeddingType(result.embedding_type || 'text-only'); } } @@ -253,6 +246,7 @@ function App() { } else { models = result.models || []; count = result.filtered_count ?? models.length; + setReturnedCount(result.returned_count ?? models.length); setEmbeddingType(result.embedding_type || 'text-only'); } } @@ -276,56 +270,124 @@ function App() { setLoading(false); fetchDataAbortRef.current = null; } - }, [minDownloads, minLikes, searchQuery, colorBy, sizeBy, projectionMethod, baseModelsOnly, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode]); + }, [minDownloads, minLikes, searchQuery, projectionMethod, baseModelsOnly, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode]); + + // Debounce times for different control types + const SLIDER_DEBOUNCE_MS = 500; // Sliders need longer debounce + const SEARCH_DEBOUNCE_MS = 300; // Search debounce + const DROPDOWN_DEBOUNCE_MS = 200; // Dropdowns need shorter debounce const debouncedFetchData = useMemo( - () => debounce(fetchData, 300), + () => debounce(fetchData, SEARCH_DEBOUNCE_MS), [fetchData] ); + // Debounced setters for sliders (minDownloads, minLikes) + // Debounced setter for search + const debouncedSetSearchQuery = useMemo( + () => debounce((query: string) => { + setSearchQuery(query); + }, SEARCH_DEBOUNCE_MS), + [setSearchQuery] + ); + + // Debounced setters for dropdowns + const debouncedSetColorBy = useMemo( + () => debounce((value: ColorByOption) => { + setColorBy(value); + }, DROPDOWN_DEBOUNCE_MS), + [setColorBy] + ); + + const debouncedSetSizeBy = useMemo( + () => debounce((value: SizeByOption) => { + setSizeBy(value); + }, DROPDOWN_DEBOUNCE_MS), + [setSizeBy] + ); + + const debouncedSetViewMode = useMemo( + () => debounce((mode: ViewMode) => { + setViewMode(mode); + }, DROPDOWN_DEBOUNCE_MS), + [setViewMode] + ); + + const debouncedSetColorScheme = useMemo( + () => debounce((scheme: FilterState['colorScheme']) => { + setColorScheme(scheme); + }, DROPDOWN_DEBOUNCE_MS), + [setColorScheme] + ); + + // Local state for search to show immediate feedback + const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); + + // Sync local state with store state when store changes externally + useEffect(() => { + setLocalSearchQuery(searchQuery); + }, [searchQuery]); + + // Cleanup debounced functions on unmount + useEffect(() => { + return () => { + debouncedSetSearchQuery.cancel(); + debouncedSetColorBy.cancel(); + debouncedSetSizeBy.cancel(); + debouncedSetViewMode.cancel(); + debouncedSetColorScheme.cancel(); + }; + }, [debouncedSetSearchQuery, debouncedSetColorBy, debouncedSetSizeBy, debouncedSetViewMode, debouncedSetColorScheme]); + + // Initial fetch on mount (with delay to allow backend to start) + useEffect(() => { + // Delay initial fetch to allow backend to start + const timer = setTimeout(() => { + fetchData(); + }, 500); // 500ms delay + + return () => clearTimeout(timer); + }, [fetchData]); // Include fetchData dependency + // Consolidated effect to handle both search and filter changes + // NOTE: colorBy and sizeBy are CLIENT-SIDE only - don't refetch data for these changes + // Skip if this is the initial mount (first 600ms) - let the initial fetch effect handle it + const hasMounted = useRef(false); useEffect(() => { - // For search queries, use debounced version - if (searchQuery) { - debouncedFetchData(); - return () => { - debouncedFetchData.cancel(); - }; - } else { - // For filter changes without search, also use debounced version - debouncedFetchData(); - return () => { - debouncedFetchData.cancel(); - }; + if (!hasMounted.current) { + hasMounted.current = true; + return; } - }, [searchQuery, minDownloads, minLikes, colorBy, sizeBy, baseModelsOnly, projectionMethod, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode, debouncedFetchData]); + + // For search queries or filter changes, use debounced version + debouncedFetchData(); + return () => { + debouncedFetchData.cancel(); + }; + }, [searchQuery, minDownloads, minLikes, baseModelsOnly, projectionMethod, semanticSimilarityMode, semanticQueryModel, useGraphEmbeddings, selectedClusters, viewMode, debouncedFetchData]); - // Function to clear cache and refresh stats - const clearCacheAndRefresh = useCallback(async () => { - try { - // Clear all caches - await cache.clear('stats'); - await cache.clear('models'); - console.log('Cache cleared successfully'); - - // Immediately fetch fresh stats - const response = await fetch(`${API_BASE}/api/stats`); - if (!response.ok) throw new Error('Failed to fetch stats'); - const statsData = await response.json(); - await cache.cacheStats('stats', statsData); - setStats(statsData); - - // Refresh model data - fetchData(); - } catch (err) { - if (err instanceof Error) { - logger.error('Error clearing cache:', err); + // Fetch live model count + useEffect(() => { + const fetchLiveCount = async () => { + try { + const response = await fetch(`${API_BASE}/api/model-count/current?use_models_page=true&use_cache=true`); + if (response.ok) { + const data = await response.json(); + setLiveModelCount(data.total_models); + } + } catch (err) { + // Silently fail - live count is optional } - } - }, [fetchData]); + }; + + fetchLiveCount(); + // Refresh every 5 minutes + const interval = setInterval(fetchLiveCount, 5 * 60 * 1000); + return () => clearInterval(interval); + }, []); useEffect(() => { - const fetchStats = async () => { + const fetchStats = async (retries = 3) => { const cacheKey = 'stats'; const cachedStats = await cache.getCachedStats(cacheKey); @@ -336,41 +398,71 @@ function App() { setStats(cachedStats); } - // Always fetch fresh stats to update - try { - const response = await fetch(`${API_BASE}/api/stats`); - if (!response.ok) throw new Error('Failed to fetch stats'); - const statsData = await response.json(); - await cache.cacheStats(cacheKey, statsData); - setStats(statsData); - } catch (err) { - if (err instanceof Error) { - logger.error('Error fetching stats:', err); + // Always fetch fresh stats to update with retry logic + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(`${API_BASE}/api/stats`); + if (!response.ok) throw new Error('Failed to fetch stats'); + const statsData = await response.json(); + await cache.cacheStats(cacheKey, statsData); + setStats(statsData); + return; // Success + } catch (err) { + if (i === retries - 1) { + // Only log on final retry failure (and not NetworkError) + if (err instanceof Error && !err.message.includes('NetworkError')) { + logger.error('Error fetching stats:', err); + } + } else { + // Wait before retry (exponential backoff) + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + } } } }; - fetchStats(); + // Delay initial fetch to allow backend to start + const timer = setTimeout(() => { + fetchStats(); + }, 1000); + + return () => clearTimeout(timer); }, []); - // Fetch clusters + // Fetch clusters with retry logic useEffect(() => { - const fetchClusters = async () => { + const fetchClusters = async (retries = 3) => { setClustersLoading(true); - try { - const response = await fetch(`${API_BASE}/api/clusters`); - if (!response.ok) throw new Error('Failed to fetch clusters'); - const data = await response.json(); - setClusters(data.clusters || []); - } catch (err) { - logger.error('Error fetching clusters:', err); - setClusters([]); - } finally { - setClustersLoading(false); + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(`${API_BASE}/api/clusters`); + if (!response.ok) throw new Error('Failed to fetch clusters'); + const data = await response.json(); + setClusters(data.clusters || []); + setClustersLoading(false); + return; // Success + } catch (err) { + if (i === retries - 1) { + // Only log on final retry failure + if (err instanceof Error && !err.message.includes('NetworkError')) { + logger.error('Error fetching clusters:', err); + } + setClusters([]); + setClustersLoading(false); + } else { + // Wait before retry (exponential backoff) + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + } + } } }; - fetchClusters(); + // Delay initial fetch to allow backend to start + const timer = setTimeout(() => { + fetchClusters(); + }, 1000); + + return () => clearTimeout(timer); }, []); // Search models for family tree lookup @@ -400,77 +492,6 @@ function App() { return () => clearTimeout(timer); }, [searchInput, searchModels]); - const loadFamilyTree = useCallback(async (modelId: string) => { - try { - const response = await fetch(`${API_BASE}/api/family/${encodeURIComponent(modelId)}?max_depth=5`); - if (!response.ok) throw new Error('Failed to load family tree'); - const data: FamilyTree = await response.json(); - setFamilyTree(data.family || []); - setFamilyTreeModelId(modelId); - setShowSearchResults(false); - setSearchInput(''); - } catch (err) { - logger.error('Family tree error:', err); - setFamilyTree([]); - setFamilyTreeModelId(null); - } - }, []); - - const clearFamilyTree = useCallback(() => { - setFamilyTree([]); - setFamilyTreeModelId(null); - }, []); - - const loadFamilyPath = useCallback(async (modelId: string, targetId?: string) => { - try { - const url = targetId - ? `${API_BASE}/api/family/path/${encodeURIComponent(modelId)}?target_id=${encodeURIComponent(targetId)}` - : `${API_BASE}/api/family/path/${encodeURIComponent(modelId)}`; - const response = await fetch(url); - if (!response.ok) throw new Error('Failed to load path'); - const data = await response.json(); - setHighlightedPath(data.path || []); - } catch (err) { - logger.error('Path loading error:', err); - setHighlightedPath([]); - } - }, []); - - const loadSimilarModels = useCallback(async (modelId: string) => { - try { - const response = await fetch(`${API_BASE}/api/similar/${encodeURIComponent(modelId)}?k=10`); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = 'Failed to load similar models'; - if (response.status === 404) { - errorMessage = 'Model not found'; - } else if (response.status === 503) { - errorMessage = 'Data not loaded yet. Please wait a moment and try again.'; - } else { - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorMessage; - } catch { - errorMessage = `Error ${response.status}: ${errorText || errorMessage}`; - } - } - throw new Error(errorMessage); - } - const data = await response.json(); - setSimilarModels(data.similar_models || []); - setShowSimilar(true); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load similar models'; - logger.error('Similar models error:', err); - if (errorMessage !== 'Failed to load similar models' || !(err instanceof TypeError && err.message.includes('fetch'))) { - setError(`Similar models: ${errorMessage}`); - setTimeout(() => setError(null), 5000); - } - setSimilarModels([]); - setShowSimilar(false); - } - }, []); - // Bookmark management const toggleBookmark = useCallback((modelId: string) => { setBookmarkedModels(prev => @@ -480,851 +501,194 @@ function App() { ); }, []); - // Comparison management - const addToComparison = useCallback((model: ModelPoint) => { - if (comparisonModels.length < 3 && !comparisonModels.find(m => m.model_id === model.model_id)) { - setComparisonModels(prev => [...prev, model]); - } - }, [comparisonModels]); - - const removeFromComparison = useCallback((modelId: string) => { - setComparisonModels(prev => prev.filter(m => m.model_id !== modelId)); - }, []); - - // Export selected models - const exportModels = useCallback(async (modelIds: string[]) => { - try { - const response = await fetch(`${API_BASE}/api/export`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(modelIds), - }); - if (!response.ok) throw new Error('Export failed'); - const data = await response.json(); - - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `models_export_${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } catch (err) { - logger.error('Export error:', err); - alert('Failed to export models'); - } - }, []); - return (
-
-
-
-

ML Ecosystem: 2M Models on Hugging Face

-
- Paper - GitHub - Dataset - Laufer, Oderinwale, Kleinberg -
-
-
- - {stats && ( - <> -
- {stats.total_models.toLocaleString()} models - {stats.unique_libraries} libraries -
- - - )} -
-
-
- -
-
+ {/* Intro Modal */} + {showIntro && !loading && data.length > 0 && ( + setShowIntro(false)} /> + )} + {loading &&
Loading models...
} {error &&
Error: {error}
} {!loading && !error && data.length === 0 && ( @@ -1332,41 +696,18 @@ function App() { )} {!loading && !error && data.length > 0 && ( <> - {viewMode === 'scatter' && ( - { - setSelectedModel(model); - setIsModalOpen(true); - }} - onBrush={(selected) => { - setSelectedModels(selected); - }} - /> - )} {viewMode === '3d' && ( - { - setSelectedModel(model); - setIsModalOpen(true); - }} - onHover={(model, position) => { - setHoveredModel(model); - if (model && position) { - setTooltipPosition(position); - } else { - setTooltipPosition(null); - } - }} - /> + <> + + )} {viewMode === 'network' && ( )} + + {/* Live Model Counter - Bottom Left */} + + + {/* Model Popup - Bottom Left */} + setIsModalOpen(false)} + onBookmark={selectedModel ? () => toggleBookmark(selectedModel.model_id) : undefined} + isBookmarked={selectedModel ? bookmarkedModels.includes(selectedModel.model_id) : false} + />
- - setIsModalOpen(false)} - onBookmark={selectedModel ? () => toggleBookmark(selectedModel.model_id) : undefined} - onAddToComparison={selectedModel ? () => addToComparison(selectedModel) : undefined} - onLoadSimilar={selectedModel ? () => loadSimilarModels(selectedModel.model_id) : undefined} - isBookmarked={selectedModel ? bookmarkedModels.includes(selectedModel.model_id) : false} - /> +
+ )} +
+ + -
); } diff --git a/frontend/src/components/controls/IntegratedSearch.css b/frontend/src/components/controls/IntegratedSearch.css new file mode 100644 index 0000000000000000000000000000000000000000..9164cfe43f5784443512380b41e6ec6d918fcf08 --- /dev/null +++ b/frontend/src/components/controls/IntegratedSearch.css @@ -0,0 +1,519 @@ +/* ============================================ + INTEGRATED SEARCH - TRIGGER INPUT + ============================================ */ + +.integrated-search-container { + position: relative; + width: 100%; + min-width: 200px; + max-width: 300px; +} + +.control-search { + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid var(--border-medium); + padding: 0.35rem 0.75rem; + background: var(--bg-primary); + cursor: pointer; + transition: border-color var(--transition-base), background-color var(--transition-base); +} + +.control-search:hover { + border-color: var(--accent-blue); + background: var(--bg-secondary); +} + +.search-icon { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.control-search-input { + border: none; + background: transparent; + color: var(--text-primary); + font-size: 0.85rem; + font-family: var(--font-primary); + flex: 1; + min-width: 0; + outline: none; + cursor: pointer; +} + +.control-search-input::placeholder { + color: var(--text-tertiary); +} + +.search-shortcut { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.search-shortcut kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 5px; + font-size: 10px; + font-family: var(--font-mono); + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + color: var(--text-tertiary); + line-height: 1; +} + +/* ============================================ + SEARCH MODAL + ============================================ */ + +.search-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 2rem; + animation: fadeIn 0.15s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.search-modal { + background: var(--bg-primary); + border: 1px solid var(--border-medium); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 640px; + max-height: 70vh; + display: flex; + flex-direction: column; + animation: slideDown 0.15s ease-out; + margin-top: -10vh; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +[data-theme="dark"] .search-modal { + background: #1a1a1a; + border-color: #3a3a3a; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); +} + +/* Modal Header */ +.search-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-bottom: 1px solid var(--border-light); +} + +.search-modal-input-wrapper { + flex: 1; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.search-modal-icon { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.search-modal-input { + flex: 1; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 1.1rem; + font-family: var(--font-primary); + outline: none; +} + +.search-modal-input::placeholder { + color: var(--text-tertiary); +} + +.search-modal-clear { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color var(--transition-base); +} + +.search-modal-clear:hover { + color: var(--text-primary); +} + +.search-modal-close { + background: none; + border: 1px solid var(--border-light); + color: var(--text-secondary); + cursor: pointer; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); +} + +.search-modal-close:hover { + background: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-medium); +} + +/* Modal Body */ +.search-modal-body { + flex: 1; + overflow-y: auto; + min-height: 200px; + max-height: calc(70vh - 140px); +} + +.search-modal-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 3rem; + color: var(--text-secondary); +} + +.search-loading-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-light); + border-top-color: var(--accent-blue); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.search-modal-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 3rem; + color: var(--text-secondary); +} + +.search-empty-hint { + margin: 0; + font-size: 0.8rem; + color: var(--text-tertiary); +} + +.search-modal-hint { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; + text-align: center; +} + +.search-modal-hint p { + margin: 0; + color: var(--text-secondary); + font-size: 0.95rem; +} + +.search-hint-examples { + color: var(--text-tertiary); + font-size: 0.85rem; +} + +.search-hint-examples code { + background: var(--bg-tertiary); + padding: 2px 6px; + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-primary); +} + +/* Fuzzy search feature hints */ +.search-hint-features { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.search-hint-feature { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.search-hint-feature svg { + color: var(--accent-blue); + flex-shrink: 0; +} + +.search-hint-feature strong { + color: var(--text-primary); +} + +/* Results Header */ +.search-results-header { + padding: 0.75rem 1rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-light); + background: var(--bg-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Results List */ +.search-results-list { + padding: 0.5rem 0; +} + +.search-result-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + cursor: pointer; + transition: background-color var(--transition-base); +} + +.search-result-item:hover, +.search-result-item.selected { + background: var(--bg-secondary); +} + +[data-theme="dark"] .search-result-item:hover, +[data-theme="dark"] .search-result-item.selected { + background: rgba(255, 255, 255, 0.05); +} + +.search-result-content { + flex: 1; + min-width: 0; +} + +.search-result-main { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.search-result-id { + font-weight: 500; + color: var(--text-primary); + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-result-similarity { + font-size: 0.75rem; + color: var(--accent-blue); + font-weight: 600; + flex-shrink: 0; + padding: 2px 6px; + background: rgba(74, 144, 226, 0.1); +} + +.search-result-relevance { + font-size: 0.7rem; + color: #10b981; + font-weight: 500; + flex-shrink: 0; + padding: 2px 6px; + background: rgba(16, 185, 129, 0.1); +} + +/* Fuzzy match highlighting */ +.search-highlight { + background: rgba(251, 191, 36, 0.3); + color: inherit; + padding: 0 1px; + font-weight: 600; +} + +[data-theme="dark"] .search-highlight { + background: rgba(251, 191, 36, 0.25); +} + +/* Search type badge */ +.search-type-badge { + display: inline-block; + padding: 2px 6px; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + margin-left: 0.5rem; +} + +.search-result-meta { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.search-result-tag { + font-size: 0.7rem; + padding: 2px 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + color: var(--text-secondary); +} + +.search-result-stat { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.7rem; + color: var(--text-tertiary); +} + +.search-result-arrow { + color: var(--text-tertiary); + flex-shrink: 0; + opacity: 0; + transition: opacity var(--transition-base); +} + +.search-result-item:hover .search-result-arrow, +.search-result-item.selected .search-result-arrow { + opacity: 1; +} + +/* Modal Footer */ +.search-modal-footer { + padding: 0.75rem 1rem; + border-top: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.search-modal-shortcuts { + display: flex; + gap: 1.5rem; + justify-content: center; +} + +.search-modal-shortcuts span { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--text-tertiary); +} + +.search-modal-shortcuts kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + padding: 2px 5px; + font-size: 10px; + font-family: var(--font-mono); + background: var(--bg-primary); + border: 1px solid var(--border-medium); + color: var(--text-secondary); +} + +/* Dark theme adjustments */ +[data-theme="dark"] .search-results-header { + background: rgba(255, 255, 255, 0.03); + border-bottom-color: rgba(255, 255, 255, 0.1); +} + +[data-theme="dark"] .search-modal-footer { + background: rgba(255, 255, 255, 0.03); + border-top-color: rgba(255, 255, 255, 0.1); +} + +[data-theme="dark"] .search-modal-header { + border-bottom-color: rgba(255, 255, 255, 0.1); +} + +/* Responsive */ +@media (max-width: 640px) { + .search-modal { + width: 95%; + max-height: 80vh; + } + + .search-modal-shortcuts { + flex-wrap: wrap; + gap: 0.75rem; + } +} + +/* Legacy dropdown styles - can be removed if not needed elsewhere */ +.integrated-search-results { + display: none; +} + +.search-loading { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + margin-left: 0.5rem; +} + +.search-clear { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.2rem; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: color var(--transition-base); + flex-shrink: 0; + margin-left: 0.25rem; +} + +.search-clear:hover { + color: var(--text-primary); +} diff --git a/frontend/src/components/controls/IntegratedSearch.tsx b/frontend/src/components/controls/IntegratedSearch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8366c48286211681ce2ac1201bcfda3f41d63843 --- /dev/null +++ b/frontend/src/components/controls/IntegratedSearch.tsx @@ -0,0 +1,435 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { X, Search, ArrowRight, Download, Heart, Sparkles } from 'lucide-react'; +import { API_BASE } from '../../config/api'; +import './IntegratedSearch.css'; + +interface SearchResult { + model_id: string; + x?: number; + y?: number; + z?: number; + similarity?: number; + family_depth?: number | null; + downloads?: number; + likes?: number; + library_name?: string | null; + pipeline_tag?: string | null; + // Fuzzy search additions + score?: number; +} + +interface IntegratedSearchProps { + value: string; + onChange: (value: string) => void; + onSelect?: (result: SearchResult) => void; + onZoomTo?: (x: number, y: number, z: number) => void; +} + +export default function IntegratedSearch({ + value, + onChange, + onSelect, + onZoomTo +}: IntegratedSearchProps) { + const [results, setResults] = useState([]); + const [totalMatches, setTotalMatches] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [searchType, setSearchType] = useState<'fuzzy' | 'semantic'>('fuzzy'); + const [localQuery, setLocalQuery] = useState(''); + + const modalInputRef = useRef(null); + const triggerInputRef = useRef(null); + const resultsRef = useRef(null); + + // Detect if query looks like a model ID (contains "/") + const isModelId = useCallback((query: string): boolean => { + return query.includes('/') && query.length >= 3; + }, []); + + // Perform fuzzy search via API (searches all 2M+ models) + const performFuzzySearch = useCallback(async (searchQuery: string) => { + if (searchQuery.length < 2) { + setResults([]); + setTotalMatches(0); + return; + } + + setIsLoading(true); + + try { + const params = new URLSearchParams({ + q: searchQuery, + limit: '100', + threshold: '50', // 50% minimum match score + }); + + const response = await fetch(`${API_BASE}/api/search/fuzzy?${params}`); + + if (!response.ok) { + throw new Error('Fuzzy search failed'); + } + + const data = await response.json(); + const models = (data.results || []).map((m: any) => ({ + model_id: m.model_id, + x: m.x || 0, + y: m.y || 0, + z: m.z || 0, + downloads: m.downloads || 0, + likes: m.likes || 0, + library_name: m.library || null, + pipeline_tag: m.pipeline || null, + family_depth: m.family_depth || null, + score: m.score, // Server-side fuzzy score (0-100) + })); + + setResults(models); + setTotalMatches(data.total_matches || models.length); + setSelectedIndex(-1); + } catch (error) { + console.error('Fuzzy search error:', error); + setResults([]); + setTotalMatches(0); + } finally { + setIsLoading(false); + } + }, []); + + // Perform semantic search for model IDs + const performSemanticSearch = useCallback(async (queryModelId: string) => { + if (!queryModelId || queryModelId.length < 3 || !queryModelId.includes('/')) { + setResults([]); + setTotalMatches(0); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const params = new URLSearchParams({ + query_model_id: queryModelId, + k: '50', + min_downloads: '0', + min_likes: '0', + projection_method: 'umap', + }); + + const response = await fetch(`${API_BASE}/api/models/semantic-similarity?${params}`); + + if (response.status === 404) { + // Model not found, fall back to fuzzy search + setSearchType('fuzzy'); + await performFuzzySearch(queryModelId); + return; + } + + if (!response.ok) { + throw new Error('Semantic search failed'); + } + + const data = await response.json(); + const models = (data.models || []).map((m: any) => ({ + model_id: m.model_id, + x: m.x, + y: m.y, + z: m.z, + similarity: m.similarity, + downloads: m.downloads, + likes: m.likes, + library_name: m.library_name, + pipeline_tag: m.pipeline_tag, + family_depth: m.family_depth, + })); + + // Sort by similarity (highest first) + models.sort((a: SearchResult, b: SearchResult) => + (b.similarity || 0) - (a.similarity || 0) + ); + + setResults(models.slice(0, 50)); + setTotalMatches(models.length); + setSelectedIndex(-1); + } catch { + // Fall back to fuzzy search on error + setSearchType('fuzzy'); + await performFuzzySearch(queryModelId); + } finally { + setIsLoading(false); + } + }, [performFuzzySearch]); + + // Handle search when local query changes + useEffect(() => { + if (localQuery.length < 2) { + setResults([]); + return; + } + + const timer = setTimeout(() => { + // Auto-detect search type: if it looks like a model ID, use semantic search + if (isModelId(localQuery)) { + setSearchType('semantic'); + performSemanticSearch(localQuery); + } else { + setSearchType('fuzzy'); + performFuzzySearch(localQuery); + } + }, 150); // Faster debounce for fuzzy search + + return () => clearTimeout(timer); + }, [localQuery, isModelId, performSemanticSearch, performFuzzySearch]); + + const handleSelect = useCallback((result: SearchResult) => { + if (onZoomTo && result.x !== undefined && result.y !== undefined) { + onZoomTo(result.x, result.y, result.z || 0); + } + + if (onSelect) { + onSelect(result); + } + + setIsModalOpen(false); + setLocalQuery(''); + onChange(''); + }, [onSelect, onZoomTo, onChange]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (results.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(prev => + prev < results.length - 1 ? prev + 1 : prev + ); + // Scroll selected item into view + if (resultsRef.current) { + const items = resultsRef.current.querySelectorAll('.search-result-item'); + const nextIndex = selectedIndex < results.length - 1 ? selectedIndex + 1 : selectedIndex; + items[nextIndex]?.scrollIntoView({ block: 'nearest' }); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); + if (resultsRef.current && selectedIndex > 0) { + const items = resultsRef.current.querySelectorAll('.search-result-item'); + items[selectedIndex - 1]?.scrollIntoView({ block: 'nearest' }); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && results[selectedIndex]) { + handleSelect(results[selectedIndex]); + } else if (results.length > 0) { + handleSelect(results[0]); + } + } else if (e.key === 'Escape') { + setIsModalOpen(false); + } + }; + + const openModal = () => { + setIsModalOpen(true); + setLocalQuery(value); + setTimeout(() => modalInputRef.current?.focus(), 50); + }; + + const closeModal = () => { + setIsModalOpen(false); + setLocalQuery(''); + setResults([]); + }; + + // Handle Cmd/Ctrl+K shortcut + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + openModal(); + } + }; + + document.addEventListener('keydown', handleGlobalKeyDown); + return () => document.removeEventListener('keydown', handleGlobalKeyDown); + }, []); + + // Format relevance score (server returns 0-100) + const formatScore = (score?: number) => { + if (score === undefined) return null; + return `${Math.round(score)}%`; + }; + + return ( + <> + {/* Trigger input in control bar */} +
+
+ + + + Cmd + K + +
+
+ + {/* Search Modal */} + {isModalOpen && ( +
+
e.stopPropagation()}> +
+
+ + setLocalQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={isModelId(localQuery) ? "Finding similar models..." : "Fuzzy search models..."} + className="search-modal-input" + autoFocus + /> + {localQuery.length > 0 && ( + + )} +
+ +
+ +
+ {isLoading && ( +
+
+ Searching... +
+ )} + + {!isLoading && localQuery.length >= 2 && results.length === 0 && ( +
+ No models found for "{localQuery}" +

Try a different spelling or fewer characters

+
+ )} + + {!isLoading && localQuery.length < 2 && ( +
+

Start typing to search models

+
+
+ + Fuzzy search — finds models even with typos +
+
+ + Semantic search — enter a model ID to find similar models +
+
+
+ Try: lama (finds llama), brt (finds bert), gpt2 +
+
+ )} + + {!isLoading && results.length > 0 && ( + <> +
+ {searchType === 'semantic' ? ( + <>Similar to "{localQuery}" + ) : ( + <>{totalMatches.toLocaleString()} matches fuzzy + )} +
+
+ {results.map((result, idx) => ( +
handleSelect(result)} + onMouseEnter={() => setSelectedIndex(idx)} + role="option" + aria-selected={idx === selectedIndex} + > +
+
+ {result.model_id} + {result.similarity !== undefined && ( + + {(result.similarity * 100).toFixed(1)}% + + )} + {searchType === 'fuzzy' && result.score !== undefined && ( + + {formatScore(result.score)} + + )} +
+
+ {result.library_name && ( + {result.library_name} + )} + {result.pipeline_tag && ( + {result.pipeline_tag} + )} + {result.downloads !== undefined && result.downloads > 0 && ( + + + {result.downloads >= 1000000 + ? `${(result.downloads / 1000000).toFixed(1)}M` + : result.downloads >= 1000 + ? `${(result.downloads / 1000).toFixed(0)}K` + : result.downloads} + + )} + {result.likes !== undefined && result.likes > 0 && ( + + + {result.likes} + + )} +
+
+ +
+ ))} +
+ + )} +
+ +
+
+ Navigate + Enter Select + Esc Close +
+
+
+
+ )} + + ); +} diff --git a/frontend/src/components/controls/SemanticSearch.css b/frontend/src/components/controls/SemanticSearch.css new file mode 100644 index 0000000000000000000000000000000000000000..8ffe9fe532baec5d0bc4fe8683a201e2a5ba67d2 --- /dev/null +++ b/frontend/src/components/controls/SemanticSearch.css @@ -0,0 +1,278 @@ +.semantic-search-container { + position: relative; + width: 100%; + max-width: 600px; +} + +.semantic-search-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 0.75rem; + padding: 0.75rem; + background: var(--bg-secondary, #f5f5f5); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 4px; +} + +[data-theme="dark"] .semantic-search-controls { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); +} + +.search-mode-toggle { + display: flex; + gap: 0.5rem; +} + +.mode-btn { + flex: 1; + padding: 0.5rem 1rem; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-medium, #ddd); + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + cursor: pointer; + transition: all 0.2s ease; +} + +.mode-btn:hover { + background: var(--bg-secondary, #f5f5f5); + border-color: var(--accent-blue, #3b82f6); +} + +.mode-btn.active { + background: var(--accent-blue, #3b82f6); + border-color: var(--accent-blue, #3b82f6); + color: #ffffff; +} + +[data-theme="dark"] .mode-btn { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: var(--text-primary); +} + +[data-theme="dark"] .mode-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--accent-blue); +} + +[data-theme="dark"] .mode-btn.active { + background: var(--accent-blue); + color: #ffffff; +} + +.query-model-input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-medium, #ddd); + border-radius: 4px; + font-size: 0.875rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #1a1a1a); +} + +[data-theme="dark"] .query-model-input { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: var(--text-primary); +} + +.search-filters { + display: flex; + gap: 0.5rem; +} + +.depth-filter-input, +.family-filter-input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-medium, #ddd); + border-radius: 4px; + font-size: 0.875rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #1a1a1a); +} + +[data-theme="dark"] .depth-filter-input, +[data-theme="dark"] .family-filter-input { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: var(--text-primary); +} + +.semantic-search-bar { + position: relative; + display: flex; + align-items: center; +} + +.semantic-search-input { + width: 100%; + padding: 0.75rem 2.5rem 0.75rem 1rem; + border: 1px solid var(--border-medium, #ddd); + border-radius: 4px; + font-size: 0.95rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #1a1a1a); +} + +[data-theme="dark"] .semantic-search-input { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: var(--text-primary); +} + +.semantic-search-input:focus { + outline: none; + border-color: var(--accent-blue, #3b82f6); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.search-loading { + position: absolute; + right: 2.5rem; + color: var(--text-secondary, #666); + animation: spin 1s linear infinite; +} + +.search-clear { + position: absolute; + right: 0.75rem; + background: none; + border: none; + color: var(--text-secondary, #666); + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + line-height: 1; +} + +.search-clear:hover { + color: var(--text-primary, #1a1a1a); +} + +.semantic-search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 400px; + overflow-y: auto; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-medium, #ddd); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + margin-top: 0.25rem; +} + +[data-theme="dark"] .semantic-search-results { + background: rgba(20, 20, 20, 0.98); + border-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); +} + +.semantic-search-result { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-light, #e8e8e8); + cursor: pointer; + transition: background 0.15s ease; +} + +[data-theme="dark"] .semantic-search-result { + border-bottom-color: rgba(255, 255, 255, 0.1); +} + +.semantic-search-result:hover, +.semantic-search-result.selected { + background: var(--bg-secondary, #f5f5f5); +} + +[data-theme="dark"] .semantic-search-result:hover, +[data-theme="dark"] .semantic-search-result.selected { + background: rgba(255, 255, 255, 0.1); +} + +.semantic-search-result:last-child { + border-bottom: none; +} + +.result-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.result-model-id { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + word-break: break-all; +} + +.similarity-badge { + padding: 0.25rem 0.5rem; + background: var(--accent-blue, #3b82f6); + color: #ffffff; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; +} + +.result-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary, #666); +} + +.result-tag { + padding: 0.25rem 0.5rem; + background: var(--bg-tertiary, #f0f0f0); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +[data-theme="dark"] .result-tag { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); +} + +.depth-tag { + background: var(--accent-green, #10b981); + color: #ffffff; + border-color: var(--accent-green, #10b981); +} + +.result-stats { + margin-left: auto; + font-size: 0.8rem; + color: var(--text-secondary, #999); +} + +.search-no-results { + padding: 2rem; + text-align: center; + color: var(--text-secondary, #666); + font-style: italic; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + + diff --git a/frontend/src/components/controls/SemanticSearch.tsx b/frontend/src/components/controls/SemanticSearch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b7a950bff694fdb3efb8f09687238463ad201b7 --- /dev/null +++ b/frontend/src/components/controls/SemanticSearch.tsx @@ -0,0 +1,398 @@ +/** + * Enhanced semantic search component with depth and family filtering. + * Supports natural language queries and semantic similarity search. + */ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { X } from 'lucide-react'; +import { API_BASE } from '../../config/api'; +import './SemanticSearch.css'; + +interface SemanticSearchResult { + model_id: string; + x: number; + y: number; + z: number; + similarity?: number; + family_depth?: number | null; + parent_model?: string | null; + downloads: number; + likes: number; + library_name?: string | null; + pipeline_tag?: string | null; +} + +interface SemanticSearchProps { + onSelect?: (result: SemanticSearchResult) => void; + onZoomTo?: (x: number, y: number, z: number) => void; + onSearchComplete?: (results: SemanticSearchResult[]) => void; +} + +export default function SemanticSearch({ + onSelect, + onZoomTo, + onSearchComplete +}: SemanticSearchProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [searchMode, setSearchMode] = useState<'semantic' | 'text'>('semantic'); + const [depthFilter, setDepthFilter] = useState(null); + const [familyFilter, setFamilyFilter] = useState(''); + const [queryModel, setQueryModel] = useState(''); + + const inputRef = useRef(null); + const resultsRef = useRef(null); + + // Parse natural language query for depth and family hints + const parseQuery = useCallback((q: string) => { + const lower = q.toLowerCase(); + let depth: number | null = null; + let family: string = ''; + let cleanQuery = q; + + // Extract depth hints: "depth 2", "at depth 3", "level 1", etc. + const depthMatch = lower.match(/(?:depth|level|at depth)\s*(\d+)/); + if (depthMatch) { + depth = parseInt(depthMatch[1], 10); + cleanQuery = cleanQuery.replace(new RegExp(depthMatch[0], 'gi'), '').trim(); + } + + // Extract family hints: "family Llama", "in Meta-Llama", etc. + const familyMatch = lower.match(/(?:family|in|from)\s+([a-zA-Z0-9\-_\/]+)/); + if (familyMatch) { + family = familyMatch[1]; + cleanQuery = cleanQuery.replace(new RegExp(familyMatch[0], 'gi'), '').trim(); + } + + return { depth, family, cleanQuery }; + }, []); + + // Perform semantic search + const performSemanticSearch = useCallback(async (queryModelId: string, depth: number | null, family: string) => { + // Validate model ID format (should be org/model-name) + if (!queryModelId || queryModelId.length < 3 || !queryModelId.includes('/')) { + setResults([]); + setIsOpen(false); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const params = new URLSearchParams({ + query_model_id: queryModelId, + k: '100', + min_downloads: '0', + min_likes: '0', + projection_method: 'umap', + }); + + const response = await fetch(`${API_BASE}/api/models/semantic-similarity?${params}`); + + if (response.status === 404) { + // Model not found - this is expected for some models + setResults([]); + setIsOpen(false); + setIsLoading(false); + return; + } + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = 'Semantic search failed'; + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.detail || errorMessage; + } catch { + errorMessage = `Error ${response.status}: ${errorText || errorMessage}`; + } + throw new Error(errorMessage); + } + + const data = await response.json(); + let models = data.models || []; + + // Filter by depth if specified + if (depth !== null) { + models = models.filter((m: SemanticSearchResult) => + m.family_depth !== null && m.family_depth !== undefined && m.family_depth === depth + ); + } + + // Filter by family if specified + if (family) { + models = models.filter((m: SemanticSearchResult) => { + const modelIdLower = m.model_id.toLowerCase(); + const parentLower = (m.parent_model || '').toLowerCase(); + const familyLower = family.toLowerCase(); + return modelIdLower.includes(familyLower) || parentLower.includes(familyLower); + }); + } + + // Sort by similarity (highest first) + models.sort((a: SemanticSearchResult, b: SemanticSearchResult) => + (b.similarity || 0) - (a.similarity || 0) + ); + + setResults(models.slice(0, 20)); // Top 20 + setIsOpen(true); + setSelectedIndex(-1); + + if (onSearchComplete) { + onSearchComplete(models); + } + } catch { + setResults([]); + } finally { + setIsLoading(false); + } + }, [onSearchComplete]); + + // Perform text search with depth/family filters + const performTextSearch = useCallback(async (searchQuery: string, depth: number | null, family: string) => { + setIsLoading(true); + try { + const params = new URLSearchParams({ + q: searchQuery, + limit: '50', + }); + + const response = await fetch(`${API_BASE}/api/search?${params}`); + if (!response.ok) throw new Error('Search failed'); + + const data = await response.json(); + let models = data.results || []; + + // Filter by depth if specified (would need to fetch family tree data) + // For now, we'll filter by family name in model_id + if (family) { + models = models.filter((m: any) => { + const modelIdLower = m.model_id.toLowerCase(); + const parentLower = (m.parent_model || '').toLowerCase(); + const familyLower = family.toLowerCase(); + return modelIdLower.includes(familyLower) || parentLower.includes(familyLower); + }); + } + + // Convert to SemanticSearchResult format + const formattedResults: SemanticSearchResult[] = models.map((m: any) => ({ + model_id: m.model_id, + x: m.x || 0, + y: m.y || 0, + z: m.z || 0, + downloads: m.downloads || 0, + likes: m.likes || 0, + library_name: m.library, + pipeline_tag: m.pipeline, + parent_model: m.parent_model, + })); + + setResults(formattedResults.slice(0, 20)); + setIsOpen(true); + setSelectedIndex(-1); + + if (onSearchComplete) { + onSearchComplete(formattedResults); + } + } catch { + setResults([]); + } finally { + setIsLoading(false); + } + }, [onSearchComplete]); + + // Handle search + useEffect(() => { + if (query.length < 2 && !queryModel) { + setResults([]); + setIsOpen(false); + return; + } + + const timer = setTimeout(() => { + const { depth, family, cleanQuery } = parseQuery(query); + + // Update filters from parsed query + if (depth !== null) setDepthFilter(depth); + if (family) setFamilyFilter(family); + + if (searchMode === 'semantic' && queryModel) { + performSemanticSearch(queryModel, depth || depthFilter, family || familyFilter); + } else if (cleanQuery.length >= 2) { + performTextSearch(cleanQuery, depth || depthFilter, family || familyFilter); + } + }, 300); + + return () => clearTimeout(timer); + }, [query, queryModel, searchMode, depthFilter, familyFilter, parseQuery, performSemanticSearch, performTextSearch]); + + const handleSelect = useCallback((result: SemanticSearchResult) => { + if (onZoomTo && result.x !== undefined && result.y !== undefined) { + onZoomTo(result.x, result.y, result.z || 0); + } + + if (onSelect) { + onSelect(result); + } + + setIsOpen(false); + setQuery(''); + setQueryModel(''); + inputRef.current?.blur(); + }, [onSelect, onZoomTo]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen || results.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(prev => + prev < results.length - 1 ? prev + 1 : prev + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && results[selectedIndex]) { + handleSelect(results[selectedIndex]); + } else if (results.length > 0) { + handleSelect(results[0]); + } + } else if (e.key === 'Escape') { + setIsOpen(false); + inputRef.current?.blur(); + } + }; + + return ( +
+
+
+ + +
+ + {searchMode === 'semantic' && ( + setQueryModel(e.target.value)} + placeholder="Reference model ID (e.g., Meta-Llama-3.1-8B-Instruct)" + className="query-model-input" + /> + )} + +
+ setDepthFilter(e.target.value ? parseInt(e.target.value, 10) : null)} + placeholder="Depth" + min="0" + className="depth-filter-input" + title="Filter by family depth (0 = root)" + /> + setFamilyFilter(e.target.value)} + placeholder="Family name" + className="family-filter-input" + title="Filter by family name" + /> +
+
+ +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => results.length > 0 && setIsOpen(true)} + placeholder={ + searchMode === 'semantic' + ? "e.g., 'depth 2 Llama models' or 'family Meta-Llama depth 3'" + : "Search models, orgs, tasks..." + } + className="semantic-search-input" + aria-label="Semantic search" + /> + {isLoading &&
Loading...
} + {query.length > 0 && !isLoading && ( + + )} +
+ + {isOpen && results.length > 0 && ( +
+ {results.map((result, idx) => ( +
handleSelect(result)} + role="option" + aria-selected={idx === selectedIndex} + > +
+ {result.model_id} + {result.similarity !== undefined && ( + + {(result.similarity * 100).toFixed(1)}% similar + + )} +
+
+ {result.family_depth !== null && result.family_depth !== undefined && ( + Depth {result.family_depth} + )} + {result.library_name && ( + {result.library_name} + )} + {result.pipeline_tag && ( + {result.pipeline_tag} + )} + + {result.downloads.toLocaleString()} downloads • {result.likes.toLocaleString()} likes + +
+
+ ))} +
+ )} + + {isOpen && query.length >= 2 && results.length === 0 && !isLoading && ( +
+
No results found
+
+ )} +
+ ); +} + diff --git a/frontend/src/components/controls/VisualizationModeButtons.tsx b/frontend/src/components/controls/VisualizationModeButtons.tsx index c73833775f70cd15d741dac757229f577b30aa8b..be3b1ba3ad4c25b2fedabe91f0d4039558e6c556 100644 --- a/frontend/src/components/controls/VisualizationModeButtons.tsx +++ b/frontend/src/components/controls/VisualizationModeButtons.tsx @@ -14,7 +14,6 @@ interface ModeOption { } const MODES: ModeOption[] = [ - { value: 'scatter', label: '2D Scatter', icon: '', description: '2D projection view' }, { value: 'network', label: 'Network', icon: '', description: 'Network graph view' }, { value: 'distribution', label: 'Distribution', icon: '', description: 'Statistical distributions' }, ]; diff --git a/frontend/src/components/layout/SearchBar.tsx b/frontend/src/components/layout/SearchBar.tsx index f2f3509b7a1d79da9d48388e0edf565710846111..99b9d91d7cfb4519f4c625c62de6772675b7c797 100644 --- a/frontend/src/components/layout/SearchBar.tsx +++ b/frontend/src/components/layout/SearchBar.tsx @@ -3,6 +3,7 @@ * Integrates with filter store and triggers map zoom/modal open. */ import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { X } from 'lucide-react'; import { useFilterStore } from '../../stores/filterStore'; import './SearchBar.css'; @@ -56,8 +57,7 @@ export default function SearchBar({ onSelect, onZoomTo }: SearchBarProps) { setResults(data.results || []); setIsOpen(true); setSelectedIndex(-1); - } catch (err) { - console.error('Search error:', err); + } catch { setResults([]); } finally { setIsLoading(false); @@ -146,7 +146,7 @@ export default function SearchBar({ onSelect, onZoomTo }: SearchBarProps) { aria-expanded={isOpen} aria-haspopup="listbox" /> - {isLoading &&
} + {isLoading &&
Loading...
} {query.length > 0 && !isLoading && ( )}
diff --git a/frontend/src/components/modals/FileTree.css b/frontend/src/components/modals/FileTree.css deleted file mode 100644 index dda91b1e956bcea87748c7f564f4b4b9ea472212..0000000000000000000000000000000000000000 --- a/frontend/src/components/modals/FileTree.css +++ /dev/null @@ -1,268 +0,0 @@ -.file-tree-container { - margin-top: 1rem; - border: 1px solid #e0e0e0; - border-radius: 0; - background: #fafafa; - max-height: 600px; - overflow-y: auto; - overflow-x: hidden; - display: flex; - flex-direction: column; -} - -.file-tree-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - background: #f5f5f5; - border-bottom: 1px solid #e0e0e0; - font-size: 0.9rem; - font-weight: 600; - flex-shrink: 0; - position: sticky; - top: 0; - z-index: 10; -} - -.file-count-badge { - background: #e3f2fd; - color: #1976d2; - padding: 0.2rem 0.5rem; - border-radius: 0; - font-size: 0.75rem; - font-weight: 500; -} - -.file-tree-link { - color: #4a90e2; - text-decoration: none; - font-size: 0.85rem; - font-weight: 400; - white-space: nowrap; -} - -.file-tree-link:hover { - text-decoration: underline; -} - -.file-tree-button { - background: #f0f0f0; - border: 1px solid #d0d0d0; - border-radius: 0; - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - cursor: pointer; - color: #333; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - transition: background 0.15s; -} - -.file-tree-button:hover { - background: #e0e0e0; -} - -.file-tree-button:active { - background: #d0d0d0; -} - -.file-tree-filters { - padding: 0.75rem 1rem; - background: #ffffff; - border-bottom: 1px solid #e0e0e0; - display: flex; - gap: 0.75rem; - flex-shrink: 0; - position: sticky; - top: 48px; - z-index: 9; -} - -.file-tree-search { - flex: 1; - position: relative; - display: flex; - align-items: center; -} - -.file-tree-search-input { - width: 100%; - padding: 0.5rem 2rem 0.5rem 0.75rem; - border: 1px solid #d0d0d0; - border-radius: 0; - font-size: 0.85rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.file-tree-search-input:focus { - outline: none; - border-color: #4a90e2; - box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1); -} - -.file-tree-clear { - position: absolute; - right: 0.5rem; - background: none; - border: none; - cursor: pointer; - color: #666; - font-size: 1rem; - padding: 0.25rem; - display: flex; - align-items: center; - justify-content: center; - border-radius: 0; -} - -.file-tree-clear:hover { - background: #f0f0f0; - color: #1a1a1a; -} - -.file-tree-type-filter { - padding: 0.5rem 0.75rem; - border: 1px solid #d0d0d0; - border-radius: 0; - font-size: 0.85rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - background: white; - cursor: pointer; - min-width: 150px; -} - -.file-tree-type-filter:focus { - outline: none; - border-color: #4a90e2; - box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1); -} - -.file-tree { - padding: 0.5rem; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 0.85rem; - flex: 1; - overflow-y: auto; -} - -.file-tree-node { - margin: 0.125rem 0; -} - -.file-tree-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.5rem; - border-radius: 0; - transition: background 0.15s; - user-select: none; -} - -.file-tree-item.directory { - cursor: pointer; -} - -.file-tree-item.directory:hover { - background: #e8f4f8; -} - -.file-tree-item.file:hover { - background: #f0f0f0; -} - -.file-actions { - display: flex; - gap: 0.25rem; - margin-left: auto; - opacity: 0; - transition: opacity 0.2s; -} - -.file-tree-item:hover .file-actions { - opacity: 1; -} - -.file-action-btn { - background: none; - border: none; - cursor: pointer; - font-size: 0.9rem; - padding: 0.25rem; - border-radius: 0; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.15s; - text-decoration: none; - color: inherit; -} - -.file-action-btn:hover { - background: rgba(0, 0, 0, 0.1); -} - -.file-icon { - font-size: 1rem; - width: 1.25rem; - text-align: center; -} - -.file-name { - flex: 1; - color: #1a1a1a; - word-break: break-all; -} - -.file-size { - color: #666; - font-size: 0.8rem; - margin-left: auto; -} - -.file-expand { - color: #666; - font-size: 0.7rem; - width: 0.75rem; - text-align: center; -} - -.file-tree-children { - margin-left: 0.5rem; - border-left: 1px solid #e8e8e8; - padding-left: 0.5rem; - margin-top: 0.125rem; -} - -.file-tree-loading, -.file-tree-error, -.file-tree-empty { - padding: 1rem; - text-align: center; - color: #666; - font-size: 0.9rem; -} - -.file-tree-error { - color: #d32f2f; -} - -/* Scrollbar styling */ -.file-tree-container::-webkit-scrollbar { - width: 8px; -} - -.file-tree-container::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 0; -} - -.file-tree-container::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 0; -} - -.file-tree-container::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; -} - diff --git a/frontend/src/components/modals/FileTree.tsx b/frontend/src/components/modals/FileTree.tsx deleted file mode 100644 index 18eb0b825a58a9ce6cceda20cdea8385d1bc22aa..0000000000000000000000000000000000000000 --- a/frontend/src/components/modals/FileTree.tsx +++ /dev/null @@ -1,509 +0,0 @@ -/** - * File tree component for displaying model file structure. - * Fetches and displays files from Hugging Face model repository. - */ -import React, { useState, useEffect, useMemo } from 'react'; -import { getHuggingFaceFileTreeUrl } from '../../utils/api/hfUrl'; -import './FileTree.css'; - -import { API_BASE } from '../../config/api'; - -interface FileNode { - path: string; - type: 'file' | 'directory'; - size?: number; - children?: FileNode[]; -} - -interface FileTreeProps { - modelId: string; -} - -export default function FileTree({ modelId }: FileTreeProps) { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [expandedPaths, setExpandedPaths] = useState>(new Set()); - const [searchQuery, setSearchQuery] = useState(''); - const [fileTypeFilter, setFileTypeFilter] = useState('all'); - const [showSearch, setShowSearch] = useState(false); - const searchInputRef = React.useRef(null); - - useEffect(() => { - if (!modelId) { - setLoading(false); - setError('No model ID provided'); - return; - } - - const fetchFiles = async () => { - setLoading(true); - setError(null); - try { - const response = await fetch( - `${API_BASE}/api/model/${encodeURIComponent(modelId)}/files?branch=main` - ); - - if (response.status === 404) { - throw new Error('File tree not available for this model'); - } - - if (response.status === 503) { - throw new Error('Backend service unavailable'); - } - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to load file tree: ${response.status} ${errorText}`); - } - - const data = await response.json(); - - if (!Array.isArray(data)) { - throw new Error('Invalid response format'); - } - - // Convert flat list to tree structure - const tree = buildFileTree(data); - setFiles(tree); - } catch (err: any) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load files'; - setError(errorMessage); - // Only log in development - if (process.env.NODE_ENV === 'development') { - console.error('Error fetching file tree:', err); - } - } finally { - setLoading(false); - } - }; - - fetchFiles(); - }, [modelId]); - - const buildFileTree = (fileList: any[]): FileNode[] => { - if (!Array.isArray(fileList) || fileList.length === 0) { - return []; - } - - const tree: FileNode[] = []; - const pathMap = new Map(); - - // Sort files by path for consistent ordering - const sortedFiles = [...fileList].sort((a, b) => { - const pathA = a.path || ''; - const pathB = b.path || ''; - return pathA.localeCompare(pathB); - }); - - for (const file of sortedFiles) { - if (!file.path) continue; - - const parts = file.path.split('/').filter((p: string) => p.length > 0); - if (parts.length === 0) continue; - - let currentPath = ''; - let parent: FileNode | null = null; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - currentPath = currentPath ? `${currentPath}/${part}` : part; - - if (!pathMap.has(currentPath)) { - const isDirectory = i < parts.length - 1; - const node: FileNode = { - path: currentPath, - type: isDirectory ? 'directory' : 'file', - size: isDirectory ? undefined : (file.size || undefined), // Only set size for files - children: isDirectory ? [] : undefined, - }; - - pathMap.set(currentPath, node); - - if (parent) { - parent.children!.push(node); - } else { - tree.push(node); - } - - parent = node; - } else { - parent = pathMap.get(currentPath)!; - } - } - } - - return tree; - }; - - const toggleExpand = (path: string) => { - setExpandedPaths((prev) => { - const next = new Set(prev); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }; - - const expandAll = () => { - const allPaths = new Set(); - const collectPaths = (nodes: FileNode[]) => { - nodes.forEach(node => { - if (node.type === 'directory' && node.children) { - allPaths.add(node.path); - if (node.children.length > 0) { - collectPaths(node.children); - } - } - }); - }; - collectPaths(files); - setExpandedPaths(allPaths); - }; - - const collapseAll = () => { - setExpandedPaths(new Set()); - }; - - const formatFileSize = (bytes?: number): string => { - if (!bytes) return ''; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - }; - - // Get all file extensions from the tree - const getAllFileExtensions = useMemo(() => { - const extensions = new Set(); - const collectExtensions = (nodes: FileNode[]) => { - nodes.forEach(node => { - if (node.type === 'file') { - const ext = node.path.split('.').pop()?.toLowerCase(); - if (ext) extensions.add(ext); - } - if (node.children) { - collectExtensions(node.children); - } - }); - }; - collectExtensions(files); - return Array.from(extensions).sort(); - }, [files]); - - // Auto-expand directories when searching - useEffect(() => { - if (searchQuery) { - const pathsToExpand = new Set(); - const findMatchingPaths = (nodes: FileNode[], query: string) => { - nodes.forEach(node => { - if (node.path.toLowerCase().includes(query.toLowerCase())) { - // Expand all parent directories - const parts = node.path.split('/'); - let currentPath = ''; - for (let i = 0; i < parts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; - pathsToExpand.add(currentPath); - } - } - if (node.children) { - findMatchingPaths(node.children, query); - } - }); - }; - findMatchingPaths(files, searchQuery); - setExpandedPaths(pathsToExpand); - } - }, [searchQuery, files]); - - // Filter files based on search and file type - const filterNodes = (nodes: FileNode[]): FileNode[] => { - return nodes - .map(node => { - const matchesSearch = !searchQuery || - node.path.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesType = fileTypeFilter === 'all' || - (node.type === 'file' && node.path.toLowerCase().endsWith(`.${fileTypeFilter}`)) || - (node.type === 'directory'); - - if (!matchesSearch || !matchesType) { - return null; - } - - const filteredChildren = node.children ? filterNodes(node.children) : undefined; - const result: FileNode | null = filteredChildren && filteredChildren.length > 0 - ? { ...node, children: filteredChildren } - : filteredChildren === undefined && matchesSearch && matchesType - ? { ...node } - : null; - return result; - }) - .filter((node): node is FileNode => node !== null); - }; - - const filteredFiles = useMemo(() => { - if (!searchQuery && fileTypeFilter === 'all') return files; - return filterNodes(files); - }, [files, searchQuery, fileTypeFilter]); - - // Count total files - const countFiles = (nodes: FileNode[]): number => { - let count = 0; - nodes.forEach(node => { - if (node.type === 'file') count++; - if (node.children) count += countFiles(node.children); - }); - return count; - }; - - const totalFileCount = useMemo(() => countFiles(files), [files]); - const visibleFileCount = useMemo(() => countFiles(filteredFiles), [filteredFiles]); - - // Keyboard shortcut for search (Cmd+K / Ctrl+K) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - setShowSearch(true); - setTimeout(() => searchInputRef.current?.focus(), 0); - } - if (e.key === 'Escape' && showSearch) { - setShowSearch(false); - setSearchQuery(''); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [showSearch]); - - const getFileIcon = (node: FileNode): string => { - if (node.type === 'directory') { - return expandedPaths.has(node.path) ? '▼' : '▶'; - } - const ext = node.path.split('.').pop()?.toLowerCase(); - const iconMap: Record = { - 'py': 'py', - 'json': 'json', - 'txt': 'txt', - 'md': 'md', - 'yml': 'yml', - 'yaml': 'yaml', - 'bin': 'bin', - 'safetensors': 'st', - 'pt': 'pt', - 'pth': 'pth', - 'onnx': 'onnx', - 'pb': 'pb', - }; - return iconMap[ext || ''] || '•'; - }; - - const copyFilePath = (path: string) => { - navigator.clipboard.writeText(path).then(() => { - // Show temporary feedback - const button = document.querySelector(`[data-file-path="${path}"]`) as HTMLElement; - if (button) { - const originalText = button.textContent; - button.textContent = 'Copied!'; - setTimeout(() => { - if (button) button.textContent = originalText; - }, 1000); - } - }); - }; - - const getFileUrl = (path: string) => { - return `https://huggingface.co/${modelId}/resolve/main/${path}`; - }; - - const renderNode = (node: FileNode, depth: number = 0): React.ReactNode => { - const isExpanded = expandedPaths.has(node.path); - const hasChildren = node.children && node.children.length > 0; - const fileName = node.path.split('/').pop() || node.path; - - return ( -
-
node.type === 'directory' && toggleExpand(node.path)} - style={{ cursor: node.type === 'directory' ? 'pointer' : 'default' }} - > - {getFileIcon(node)} - {fileName} - {node.type === 'file' && node.size && ( - {formatFileSize(node.size)} - )} - {node.type === 'directory' && ( - {isExpanded ? '▼' : '▶'} - )} - {node.type === 'file' && ( -
e.stopPropagation()}> - - e.stopPropagation()} - > - Download - -
- )} -
- {isExpanded && hasChildren && ( -
- {node.children!.map((child) => renderNode(child, depth + 1))} -
- )} -
- ); - }; - - if (loading) { - return ( -
-
Loading file tree...
-
- ); - } - - if (error) { - return ( -
-
- {error} -
- File tree may not be available for this model. -
-
-
- ); - } - - if (files.length === 0) { - return ( -
-
No files found
-
- ); - } - - const hasDirectories = files.some(node => node.type === 'directory'); - - return ( -
-
-
- Repository Files - - {visibleFileCount === totalFileCount - ? `${totalFileCount} file${totalFileCount !== 1 ? 's' : ''}` - : `${visibleFileCount} of ${totalFileCount} files`} - -
-
- - {hasDirectories && ( - <> - - - - )} - - View on HF → - -
-
- - {/* Search and Filter Bar */} - {(showSearch || searchQuery || fileTypeFilter !== 'all') && ( -
-
- setSearchQuery(e.target.value)} - className="file-tree-search-input" - /> - {searchQuery && ( - - )} -
- {getAllFileExtensions.length > 0 && ( - - )} -
- )} - -
- {filteredFiles.length === 0 ? ( -
- {searchQuery || fileTypeFilter !== 'all' - ? 'No files match your filters' - : 'No files found'} -
- ) : ( - filteredFiles.map((node) => renderNode(node)) - )} -
-
- ); -} - diff --git a/frontend/src/components/modals/ModelModal.css b/frontend/src/components/modals/ModelModal.css deleted file mode 100644 index dae7107e1066b1b513783e9724f8a7df3e422134..0000000000000000000000000000000000000000 --- a/frontend/src/components/modals/ModelModal.css +++ /dev/null @@ -1,533 +0,0 @@ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 2rem; - animation: fadeIn 0.2s ease-in; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.modal-content { - background: #ffffff; - border-radius: 0; - max-width: 900px; - width: 100%; - max-height: 90vh; - overflow-y: auto; - padding: 0; - position: relative; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); - border: 1px solid #d0d0d0; - animation: slideUp 0.3s ease-out; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - display: flex; - flex-direction: column; -} - -.modal-content[data-tab="files"] { - max-width: 1000px; - max-height: 95vh; -} - -@keyframes slideUp { - from { - transform: translateY(20px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -.modal-close { - position: absolute; - top: 1rem; - right: 1rem; - background: none; - border: 1px solid #d0d0d0; - font-size: 0.85rem; - line-height: 1; - cursor: pointer; - color: #6a6a6a; - padding: 0.4rem 0.8rem; - display: flex; - align-items: center; - justify-content: center; - border-radius: 0; - transition: all 0.2s; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.modal-close:hover { - background: #f0f0f0; - color: #1a1a1a; -} - -.modal-header { - padding: 1.5rem 2rem; - border-bottom: 1px solid #e0e0e0; - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 1rem; - background: #fafafa; -} - -.modal-content h2 { - margin: 0; - font-size: 1.5rem; - color: #1a1a1a; - word-break: break-word; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - font-weight: 600; - line-height: 1.3; -} - -.modal-body { - padding: 1.5rem 2rem; - flex: 1; - overflow-y: auto; -} - -.modal-actions { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-bottom: 1.5rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid #e0e0e0; -} - -.action-btn { - padding: 0.5rem 1rem; - background: #6a6a6a; - color: white; - border: none; - border-radius: 0; - cursor: pointer; - font-size: 0.85rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - transition: all 0.2s; - font-weight: 500; -} - -.action-btn:hover { - background: #4a4a4a; - transform: translateY(-1px); -} - -.action-btn.active { - background: #4a4a4a; -} - -.modal-tabs { - display: flex; - gap: 0.5rem; - margin-bottom: 1.5rem; - border-bottom: 2px solid #e0e0e0; - position: sticky; - top: 0; - background: #ffffff; - z-index: 10; - padding-top: 0.5rem; - margin-top: -0.5rem; -} - -.modal-tab { - padding: 0.75rem 1.5rem; - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - font-size: 0.9rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - color: #666; - font-weight: 500; - margin-bottom: -2px; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 0.5rem; - position: relative; -} - -.tab-icon { - font-size: 1rem; -} - -.tab-badge { - background: #4a90e2; - color: white; - font-size: 0.7rem; - padding: 0.15rem 0.4rem; - border-radius: 0; - font-weight: 600; - margin-left: 0.25rem; -} - -.modal-tab:hover { - color: #1a1a1a; - background: #f5f5f5; -} - -.modal-tab.active { - color: #1a1a1a; - border-bottom-color: #1a1a1a; - font-weight: 600; -} - -.modal-info-section { - min-height: 200px; -} - -.info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1.5rem; - margin-bottom: 1.5rem; -} - -.info-item { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.info-label { - font-size: 0.8rem; - color: #666; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.info-value { - font-size: 1.1rem; - color: #1a1a1a; - font-weight: 500; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.info-value.highlight { - font-size: 1.3rem; - font-weight: 600; - color: #1a1a1a; -} - -.info-value.colored { - font-size: 1.1rem; -} - -.info-value.coordinates { - display: flex; - flex-direction: column; - gap: 0.25rem; - font-size: 0.95rem; - font-family: 'Monaco', 'Menlo', monospace; - color: #4a4a4a; -} - -.info-section { - margin-bottom: 1.5rem; - padding: 1rem; - background: #fafafa; - border-radius: 0; - border: 1px solid #e0e0e0; -} - -.section-title { - font-size: 0.85rem; - color: #666; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; - margin-bottom: 0.75rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.section-content { - font-size: 0.95rem; - color: #1a1a1a; - line-height: 1.6; -} - -.tag-list { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.tag { - display: inline-block; - padding: 0.35rem 0.75rem; - background: #e8f4f8; - color: #1a1a1a; - border-radius: 0; - font-size: 0.85rem; - font-weight: 500; - border: 1px solid #c8e6c9; -} - -.tag.license-tag { - background: #fff3e0; - border-color: #ffcc80; -} - -.model-link { - color: #4a90e2; - text-decoration: none; - font-weight: 500; - word-break: break-all; -} - -.model-link:hover { - text-decoration: underline; -} - -.modal-section { - margin-bottom: 1.5rem; -} - -.modal-section:last-child { - margin-bottom: 0; -} - -.modal-section h3 { - margin: 0 0 0.75rem 0; - font-size: 1rem; - font-weight: 600; - color: #4a4a4a; - text-transform: uppercase; - letter-spacing: 0.5px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.modal-info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; -} - -.modal-info-item { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.modal-info-item strong { - font-size: 0.875rem; - color: #6a6a6a; - font-weight: 500; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.modal-info-item span { - font-size: 1rem; - color: #1a1a1a; - font-weight: 500; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.modal-tags { - margin: 0; - padding: 0.75rem; - background: #f5f5f5; - border-radius: 0; - color: #1a1a1a; - font-size: 0.9rem; - line-height: 1.5; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} - -.modal-footer { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid #e0e0e0; - display: flex; - justify-content: center; -} - -.modal-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: #1a1a1a; - color: #ffffff; - text-decoration: none; - border-radius: 0; - font-weight: 500; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - transition: all 0.2s; - border: 1px solid #1a1a1a; -} - -.modal-link:hover { - background: #4a4a4a; - border-color: #4a4a4a; - transform: translateY(-1px); -} - -@media (max-width: 768px) { - .modal-content { - max-width: 100%; - margin: 1rem; - } - - .modal-header { - padding: 1rem 1.5rem; - } - - .modal-body { - padding: 1rem 1.5rem; - } - - .info-grid { - grid-template-columns: 1fr; - } - - .modal-tabs { - overflow-x: auto; - } -} - -/* Papers Section */ -.papers-loading, -.papers-error, -.papers-empty { - text-align: center; - padding: 2rem; - color: #666; -} - -.papers-error { - color: #d32f2f; - background: #ffebee; - border-radius: 0; - padding: 1rem; -} - -.papers-list { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.paper-card { - background: #f9f9f9; - border: 1px solid #e0e0e0; - border-radius: 0; - padding: 1.5rem; - transition: box-shadow 0.2s; -} - -.paper-card:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.paper-header { - margin-bottom: 1rem; -} - -.paper-title { - margin: 0 0 0.5rem 0; - font-size: 1.25rem; - line-height: 1.4; -} - -.paper-link { - color: #1976d2; - text-decoration: none; - transition: color 0.2s; -} - -.paper-link:hover { - color: #1565c0; - text-decoration: underline; -} - -.paper-id { - font-size: 0.875rem; - color: #666; -} - -.arxiv-link { - color: #b31b1b; - text-decoration: none; - font-weight: 500; - transition: color 0.2s; -} - -.arxiv-link:hover { - color: #8b0000; - text-decoration: underline; -} - -.paper-authors, -.paper-date, -.paper-categories { - margin-bottom: 0.75rem; - font-size: 0.9rem; - color: #555; -} - -.paper-authors strong, -.paper-date strong, -.paper-categories strong { - color: #333; - margin-right: 0.5rem; -} - -.paper-categories { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; -} - -.category-tag { - background: #e3f2fd; - color: #1976d2; - padding: 0.25rem 0.5rem; - border-radius: 0; - font-size: 0.8rem; - font-weight: 500; -} - -.paper-abstract { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid #e0e0e0; -} - -.paper-abstract strong { - display: block; - margin-bottom: 0.5rem; - color: #333; - font-size: 0.95rem; -} - -.paper-abstract p { - margin: 0; - line-height: 1.6; - color: #555; - text-align: justify; -} - diff --git a/frontend/src/components/modals/ModelModal.tsx b/frontend/src/components/modals/ModelModal.tsx deleted file mode 100644 index a6a080db29b73c451c6ba18b7e1cce027c18bc1a..0000000000000000000000000000000000000000 --- a/frontend/src/components/modals/ModelModal.tsx +++ /dev/null @@ -1,428 +0,0 @@ -/** - * Modal component for displaying detailed model information. - * Enhanced with bookmark, comparison, similar models, and file tree features. - */ -import React, { useState, useEffect } from 'react'; -import { ModelPoint } from '../../types'; -import FileTree from './FileTree'; -import { getHuggingFaceUrl } from '../../utils/api/hfUrl'; -import { API_BASE } from '../../config/api'; -import './ModelModal.css'; - -interface ArxivPaper { - arxiv_id: string; - title: string; - abstract: string; - authors: string[]; - published: string; - categories: string[]; - url: string; -} - -interface ModelModalProps { - model: ModelPoint | null; - isOpen: boolean; - onClose: () => void; - onBookmark?: (modelId: string) => void; - onAddToComparison?: (model: ModelPoint) => void; - onLoadSimilar?: (modelId: string) => void; - isBookmarked?: boolean; -} - -export default function ModelModal({ - model, - isOpen, - onClose, - onBookmark, - onAddToComparison, - onLoadSimilar, - isBookmarked = false, -}: ModelModalProps) { - const [activeTab, setActiveTab] = useState<'details' | 'files' | 'papers'>('details'); - const [papers, setPapers] = useState([]); - const [papersLoading, setPapersLoading] = useState(false); - const [papersError, setPapersError] = useState(null); - - // Fetch arXiv papers when model changes - useEffect(() => { - if (!isOpen || !model) { - setPapers([]); - return; - } - - const fetchPapers = async () => { - setPapersLoading(true); - setPapersError(null); - try { - const response = await fetch(`${API_BASE}/api/model/${encodeURIComponent(model.model_id)}/papers`); - if (!response.ok) throw new Error('Failed to fetch papers'); - const data = await response.json(); - setPapers(data.papers || []); - } catch (err) { - setPapersError(err instanceof Error ? err.message : 'Failed to load papers'); - setPapers([]); - } finally { - setPapersLoading(false); - } - }; - - fetchPapers(); - }, [model?.model_id, isOpen]); - - if (!isOpen || !model) return null; - - const hfUrl = getHuggingFaceUrl(model.model_id); - - // Parse tags if it's a string representation of an array - const parseTags = (tags: string | null | undefined): string[] => { - if (!tags) return []; - try { - // Try to parse as JSON array - if (tags.startsWith('[') && tags.endsWith(']')) { - // Replace single quotes with double quotes for valid JSON - const jsonString = tags.replace(/'/g, '"'); - return JSON.parse(jsonString); - } - // Otherwise split by comma - return tags.split(',').map(t => t.trim().replace(/['"]/g, '')); - } catch { - // If parsing fails, try to extract values from string representation - try { - // Handle cases like "['tag1', 'tag2']" - const cleaned = tags.replace(/^\[|\]$/g, '').replace(/'/g, '"'); - const parsed = JSON.parse(`[${cleaned}]`); - return Array.isArray(parsed) ? parsed : [tags]; - } catch { - return [tags]; - } - } - }; - - const tags = parseTags(model.tags); - - // Parse licenses - handle both JSON arrays and string representations with single quotes - const parseLicenses = (licenses: string | null | undefined): string[] => { - if (!licenses) return []; - try { - // If it's already a valid JSON array string, parse it - if (licenses.startsWith('[') && licenses.endsWith(']')) { - // Replace single quotes with double quotes for valid JSON - const jsonString = licenses.replace(/'/g, '"'); - return JSON.parse(jsonString); - } - // Otherwise, treat as a single license string - return [licenses]; - } catch { - // If parsing fails, try to extract values from string representation - try { - // Handle cases like "['apache-2.0']" or "['license1', 'license2']" - const cleaned = licenses.replace(/^\[|\]$/g, '').replace(/'/g, '"'); - const parsed = JSON.parse(`[${cleaned}]`); - return Array.isArray(parsed) ? parsed : [licenses]; - } catch { - // Last resort: return as single-item array - return [licenses]; - } - } - }; - - const licenses = parseLicenses(model.licenses); - - // Color coding functions - const getLibraryColor = (library: string | null | undefined): string => { - if (!library) return '#cccccc'; - const colors: Record = { - 'transformers': '#1f77b4', - 'diffusers': '#ff7f0e', - 'sentence-transformers': '#2ca02c', - 'timm': '#d62728', - 'speechbrain': '#9467bd', - }; - return colors[library.toLowerCase()] || '#6a6a6a'; - }; - - const getPipelineColor = (pipeline: string | null | undefined): string => { - if (!pipeline || pipeline === 'Unknown') return '#cccccc'; - const colors: Record = { - 'text-classification': '#1f77b4', - 'token-classification': '#ff7f0e', - 'question-answering': '#2ca02c', - 'summarization': '#d62728', - 'translation': '#9467bd', - 'text-generation': '#8c564b', - }; - return colors[pipeline.toLowerCase()] || '#6a6a6a'; - }; - - return ( -
-
e.stopPropagation()} - data-tab={activeTab} - > -
-

{model.model_id}

- -
- -
- {/* Action Buttons */} -
- {onBookmark && ( - - )} - {onAddToComparison && ( - - )} - {onLoadSimilar && ( - - )} -
- - {/* Tabs */} -
- - - {(papers.length > 0 || papersLoading) && ( - - )} -
- - {/* Tab Content */} - {activeTab === 'details' && ( -
-
-
-
Library
-
- {model.library_name || 'Unknown'} -
-
- -
-
Pipeline / Task
-
- {model.pipeline_tag || 'Unknown'} -
-
- -
-
Downloads
-
- {model.downloads.toLocaleString()} -
-
- -
-
Likes
-
- {model.likes.toLocaleString()} -
-
- - {model.trending_score !== null && ( -
-
Trending Score
-
- {model.trending_score.toFixed(2)} -
-
- )} - -
-
Coordinates
-
- X: {model.x.toFixed(3)} - Y: {model.y.toFixed(3)} - Z: {model.z.toFixed(3)} -
-
-
- - {model.parent_model && ( -
-
Parent Model
- -
- )} - - {licenses.length > 0 && ( -
-
License{licenses.length > 1 ? 's' : ''}
-
-
- {licenses.map((license: string, idx: number) => ( - - {license} - - ))} -
-
-
- )} - - {tags.length > 0 && ( -
-
Tags
-
-
- {tags.map((tag: string, idx: number) => ( - - {tag} - - ))} -
-
-
- )} -
- )} - - {activeTab === 'files' && ( -
- -
- )} - - {activeTab === 'papers' && ( -
- {papersLoading ? ( -
Loading papers...
- ) : papersError ? ( -
Error loading papers: {papersError}
- ) : papers.length === 0 ? ( -
No arXiv papers found for this model.
- ) : ( -
- {papers.map((paper, idx) => ( -
- - - {paper.authors && paper.authors.length > 0 && ( -
- Authors: {paper.authors.join(', ')} -
- )} - - {paper.published && ( -
- Published: {new Date(paper.published).toLocaleDateString()} -
- )} - - {paper.categories && paper.categories.length > 0 && ( -
- Categories:{' '} - {paper.categories.map((cat, i) => ( - - {cat} - - ))} -
- )} - - {paper.abstract && ( -
- Abstract: -

{paper.abstract}

-
- )} -
- ))} -
- )} -
- )} - - -
-
-
- ); -} diff --git a/frontend/src/components/ui/ColorLegend.css b/frontend/src/components/ui/ColorLegend.css index 24044453e1775db68a6094a25717b735a34af213..564434b537b46b87181d462c48731b7d45f7aeff 100644 --- a/frontend/src/components/ui/ColorLegend.css +++ b/frontend/src/components/ui/ColorLegend.css @@ -1,14 +1,28 @@ .color-legend { position: absolute; - background: rgba(255, 255, 255, 0.95); - border: 1px solid #e0e0e0; - border-radius: 0; - padding: 0.75rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + background: var(--bg-elevated, #ffffff); + border: 1px solid var(--border-medium, #d0d0d0); + padding: 1rem; + box-shadow: var(--shadow-lg, 0 2px 8px rgba(0, 0, 0, 0.12)); z-index: 100; font-size: 0.875rem; - max-height: 400px; + max-height: 450px; overflow-y: auto; + backdrop-filter: blur(12px); + color: var(--text-primary, #1a1a1a); + transition: box-shadow var(--transition-base, 0.2s ease), border-color var(--transition-base, 0.2s ease); +} + +[data-theme="dark"] .color-legend { + background: rgba(20, 20, 20, 0.96); + border: 1px solid rgba(255, 255, 255, 0.25); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6); + color: #ffffff; +} + +.color-legend:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7); + border-color: rgba(255, 255, 255, 0.3); } .legend-top-right { @@ -32,11 +46,18 @@ } .legend-header { - margin-bottom: 0.5rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid #e0e0e0; - font-size: 0.9rem; - color: #333; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-light, #e8e8e8); + font-size: 0.95rem; + color: var(--text-primary, #1a1a1a); + font-weight: 600; + letter-spacing: -0.01em; +} + +[data-theme="dark"] .legend-header { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); + color: #ffffff; } .legend-content { @@ -51,6 +72,23 @@ gap: 0.25rem; max-height: 300px; overflow-y: auto; + padding-right: 0.25rem; +} + +.legend-categorical::-webkit-scrollbar { + width: 6px; +} + +.legend-categorical::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); +} + +.legend-categorical::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); +} + +.legend-categorical::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.5); } .legend-item { @@ -58,24 +96,44 @@ align-items: center; gap: 0.5rem; padding: 0.25rem 0; + transition: opacity 0.2s; +} + +.legend-item:hover { + opacity: 0.8; +} + +.legend-item-more { + opacity: 0.7; + font-size: 0.75rem; + margin-top: 0.5rem; } .legend-color { - width: 16px; - height: 16px; - border-radius: 0; - border: 1px solid #ccc; + width: 18px; + height: 18px; + border: 1px solid var(--border-medium, #d0d0d0); flex-shrink: 0; + box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05)); +} + +[data-theme="dark"] .legend-color { + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } .legend-label { - color: #555; + color: var(--text-secondary, #666666); font-size: 0.8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +[data-theme="dark"] .legend-label { + color: #e0e0e0; +} + .legend-continuous { display: flex; flex-direction: column; @@ -84,10 +142,15 @@ .legend-gradient { display: flex; - height: 20px; - border-radius: 0; + height: 24px; overflow: hidden; - border: 1px solid #ccc; + border: 1px solid var(--border-medium, #d0d0d0); + box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05)); +} + +[data-theme="dark"] .legend-gradient { + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } .legend-gradient-segment { @@ -99,7 +162,12 @@ display: flex; justify-content: space-between; font-size: 0.75rem; - color: #666; + color: var(--text-secondary, #666666); + margin-top: 0.25rem; +} + +[data-theme="dark"] .legend-labels { + color: #e0e0e0; } @media (max-width: 768px) { diff --git a/frontend/src/components/ui/ColorLegend.tsx b/frontend/src/components/ui/ColorLegend.tsx index aa84290c637fc7d76196c82e74a33beee7e44603..6590d20219b52592715731793cf6876e8c6eb3a1 100644 --- a/frontend/src/components/ui/ColorLegend.tsx +++ b/frontend/src/components/ui/ColorLegend.tsx @@ -2,8 +2,8 @@ * Interactive color legend component for visualizations. * Shows color mappings for categorical and continuous data. */ -import React from 'react'; -import { getCategoricalColorMap, getContinuousColorScale } from '../../utils/rendering/colors'; +import React, { useMemo } from 'react'; +import { getCategoricalColorMap, getContinuousColorScale, getDepthColorScale } from '../../utils/rendering/colors'; import './ColorLegend.css'; interface ColorLegendProps { @@ -11,75 +11,160 @@ interface ColorLegendProps { data: any[]; width?: number; position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; + isDarkMode?: boolean; } -export default function ColorLegend({ colorBy, data, width = 200, position = 'top-right' }: ColorLegendProps) { - if (!data || data.length === 0) return null; +function ColorLegend({ colorBy, data, width = 200, position = 'top-right', isDarkMode }: ColorLegendProps) { + // Memoize legend calculation to prevent recalculation on every render + const { legendItems, isContinuous, isCategorical, positionClass } = useMemo(() => { + if (!data || data.length === 0) { + return { legendItems: [], isContinuous: false, isCategorical: false, positionClass: `legend-${position}` }; + } - const isCategorical = colorBy === 'library_name' || colorBy === 'pipeline_tag' || colorBy === 'cluster_id'; - - // Get unique categories or value range - let legendItems: Array<{ label: string; color: string }> = []; - - if (isCategorical) { - const categories = Array.from(new Set(data.map((d: any) => { - if (colorBy === 'library_name') return d.library_name || 'unknown'; - if (colorBy === 'pipeline_tag') return d.pipeline_tag || 'unknown'; - if (colorBy === 'cluster_id') return d.cluster_id !== null ? `Cluster ${d.cluster_id}` : 'No cluster'; - return 'unknown'; - }))).sort(); - - const colorScheme = colorBy === 'library_name' ? 'library' : colorBy === 'pipeline_tag' ? 'pipeline' : 'default'; - const colorMap = getCategoricalColorMap(categories, colorScheme); + let isCat = colorBy === 'library_name' || colorBy === 'pipeline_tag' || colorBy === 'cluster_id'; + let items: Array<{ label: string; color: string }> = []; + let isCont = false; - legendItems = categories.map(cat => ({ - label: cat, - color: colorMap.get(cat) || '#808080' - })); - } else if (colorBy === 'family_depth') { - const depths = data.map((d: any) => d.family_depth ?? 0); - const maxDepth = Math.max(...depths, 1); - const scale = getContinuousColorScale(0, maxDepth, 'plasma'); - - // Create gradient legend - const steps = 10; - for (let i = 0; i <= steps; i++) { - const depth = (i / steps) * maxDepth; - legendItems.push({ - label: `Depth ${Math.round(depth)}`, - color: scale(depth) + if (isCat) { + const categories = Array.from(new Set(data.map((d: any) => { + if (colorBy === 'library_name') return d.library_name || 'unknown'; + if (colorBy === 'pipeline_tag') return d.pipeline_tag || 'unknown'; + if (colorBy === 'cluster_id') return d.cluster_id !== null ? `Cluster ${d.cluster_id}` : 'No cluster'; + return 'unknown'; + }))).sort(); + + const colorSchemeType = colorBy === 'library_name' ? 'library' : colorBy === 'pipeline_tag' ? 'pipeline' : 'default'; + const colorMap = getCategoricalColorMap(categories, colorSchemeType); + + // Limit to top 20 categories for readability + const topCategories = categories.slice(0, 20); + items = topCategories.map(cat => ({ + label: cat.length > 25 ? cat.substring(0, 22) + '...' : cat, + color: colorMap.get(cat) || '#60a5fa' + })); + } else if (colorBy === 'family_depth') { + isCont = true; + const depths = data.map((d: any) => d.family_depth ?? 0); + const maxDepth = Math.max(...depths, 1); + const minDepth = Math.min(...depths); + const uniqueDepths = new Set(depths); + + // Use dark mode state if provided, otherwise detect from document + const darkMode = isDarkMode !== undefined + ? isDarkMode + : document.documentElement.getAttribute('data-theme') === 'dark'; + + // If all depths are the same or very few unique depths, show a simpler legend + if (uniqueDepths.size <= 2 && maxDepth === 0) { + // All models are root - show library-based legend instead + const categories = Array.from(new Set(data.map((d: any) => d.library_name || 'unknown'))); + const colorMap = getCategoricalColorMap(categories, 'library'); + const topCategories = categories.slice(0, 10); + items = topCategories.map(cat => ({ + label: cat.length > 20 ? cat.substring(0, 17) + '...' : cat, + color: colorMap.get(cat) || '#4a90e2' + })); + isCont = false; + isCat = true; + } else { + const scale = getDepthColorScale(maxDepth, darkMode); + + // Create gradient legend showing depth progression + const steps = 8; + for (let i = 0; i <= steps; i++) { + const depth = (i / steps) * maxDepth; + let label = ''; + if (i === 0) { + label = `${minDepth} (Root)`; + } else if (i === steps) { + label = `${Math.round(maxDepth)} (Deep)`; + } else if (i === Math.floor(steps / 2)) { + label = `${Math.round(depth)}`; + } + items.push({ + label, + color: scale(depth) + }); + } + } + } else if (colorBy === 'downloads' || colorBy === 'likes') { + isCont = true; + const values = data.map((d: any) => { + if (colorBy === 'downloads') return d.downloads; + return d.likes; }); + const min = Math.min(...values); + const max = Math.max(...values); + const scale = getContinuousColorScale(min, max, 'viridis', true); // Use log scale + + // Create gradient legend + const steps = 8; + for (let i = 0; i <= steps; i++) { + const logMin = Math.log10(min + 1); + const logMax = Math.log10(max + 1); + const logValue = logMin + (i / steps) * (logMax - logMin); + const value = Math.pow(10, logValue) - 1; + + let label = ''; + if (i === 0) { + label = min >= 1000 ? `${(min / 1000).toFixed(1)}K` : Math.round(min).toString(); + } else if (i === steps) { + label = max >= 1000000 ? `${(max / 1000000).toFixed(1)}M` : max >= 1000 ? `${(max / 1000).toFixed(1)}K` : Math.round(max).toString(); + } + + items.push({ + label, + color: scale(value) + }); + } + } else if (colorBy === 'trending_score') { + isCont = true; + const scores = data.map((d: any) => d.trending_score ?? 0); + const min = Math.min(...scores); + const max = Math.max(...scores); + const scale = getContinuousColorScale(min, max, 'plasma', false); + + const steps = 8; + for (let i = 0; i <= steps; i++) { + const value = min + (i / steps) * (max - min); + items.push({ + label: i === 0 ? min.toFixed(1) : i === steps ? max.toFixed(1) : '', + color: scale(value) + }); + } } - } else { - // Continuous scale (downloads, likes) - const values = data.map((d: any) => { - if (colorBy === 'downloads') return d.downloads; - return d.likes; - }); - const min = Math.min(...values); - const max = Math.max(...values); - const scale = getContinuousColorScale(min, max, 'viridis'); - // Create gradient legend - const steps = 10; - for (let i = 0; i <= steps; i++) { - const value = min + (i / steps) * (max - min); - legendItems.push({ - label: value >= 1000 ? `${(value / 1000).toFixed(1)}K` : Math.round(value).toString(), - color: scale(value) - }); - } - } + return { + legendItems: items, + isContinuous: isCont, + isCategorical: isCat, + positionClass: `legend-${position}` + }; + }, [data, colorBy, position, isDarkMode]); // Only recalculate when data, colorBy, position, or isDarkMode changes + + const getTitle = useMemo(() => { + const titles: Record = { + 'library_name': 'Library', + 'pipeline_tag': 'Pipeline / Task', + 'cluster_id': 'Cluster', + 'family_depth': 'Family Depth', + 'downloads': 'Downloads', + 'likes': 'Likes', + 'trending_score': 'Trending Score', + 'licenses': 'License' + }; + return titles[colorBy] || colorBy.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); + }, [colorBy]); - const positionClass = `legend-${position}`; + if (!data || data.length === 0 || legendItems.length === 0) return null; return (
- {colorBy.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())} + {getTitle}
- {isCategorical || colorBy === 'family_depth' ? ( + {isCategorical ? (
{legendItems.map((item, idx) => (
@@ -87,9 +172,14 @@ export default function ColorLegend({ colorBy, data, width = 200, position = 'to className="legend-color" style={{ backgroundColor: item.color }} /> - {item.label} + {item.label}
))} + {legendItems.length >= 20 && ( +
+ ... and more +
+ )}
) : (
@@ -103,8 +193,8 @@ export default function ColorLegend({ colorBy, data, width = 200, position = 'to ))}
- {legendItems[0].label} - {legendItems[legendItems.length - 1].label} + {legendItems[0].label || 'Min'} + {legendItems[legendItems.length - 1].label || 'Max'}
)} @@ -113,3 +203,11 @@ export default function ColorLegend({ colorBy, data, width = 200, position = 'to ); } +// Memoize the component to prevent unnecessary re-renders +export default React.memo(ColorLegend, (prevProps, nextProps) => { + // Only re-render if colorBy changes or data length changes significantly + return prevProps.colorBy === nextProps.colorBy && + prevProps.position === nextProps.position && + prevProps.width === nextProps.width && + Math.abs(prevProps.data.length - nextProps.data.length) < 100; // Allow small fluctuations +}); diff --git a/frontend/src/components/ui/ErrorBoundary.css b/frontend/src/components/ui/ErrorBoundary.css new file mode 100644 index 0000000000000000000000000000000000000000..fe7b42b6db304713941130639dde8128d491d353 --- /dev/null +++ b/frontend/src/components/ui/ErrorBoundary.css @@ -0,0 +1,62 @@ +/* ============================================ + ERROR BOUNDARY + ============================================ */ + +.error-boundary-container { + padding: 2rem; + margin: 2rem; + background: var(--bg-secondary); + border: 1px solid var(--border-medium); + color: var(--text-primary); +} + +.error-boundary-title { + margin-top: 0; + margin-bottom: 1rem; + color: #ef4444; +} + +.error-boundary-message { + margin-bottom: 1rem; + color: var(--text-secondary); +} + +.error-boundary-details { + margin-top: 1rem; +} + +.error-boundary-summary { + cursor: pointer; + font-weight: bold; + color: var(--text-primary); +} + +.error-boundary-stack { + margin-top: 0.5rem; + padding: 1rem; + background: var(--bg-primary); + border: 1px solid var(--border-light); + overflow-x: auto; + font-size: 0.875rem; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + color: var(--text-secondary); +} + +.error-boundary-reset-btn { + margin-top: 1rem; + padding: 0.5rem 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-medium); + color: var(--text-primary); + cursor: pointer; + font-size: 0.875rem; + transition: all var(--transition-base); +} + +.error-boundary-reset-btn:hover { + background: var(--bg-secondary); + border-color: var(--accent-blue); +} + diff --git a/frontend/src/components/ui/ErrorBoundary.tsx b/frontend/src/components/ui/ErrorBoundary.tsx index c8b7dfef738f565be9c408730c760485572acfc2..180079f2b9c01747fd208c3b8f831a3fcd9f0b7d 100644 --- a/frontend/src/components/ui/ErrorBoundary.tsx +++ b/frontend/src/components/ui/ErrorBoundary.tsx @@ -2,6 +2,7 @@ * Error Boundary component to catch and display React errors gracefully */ import React, { Component, ErrorInfo, ReactNode } from 'react'; +import './ErrorBoundary.css'; interface Props { children: ReactNode; @@ -33,11 +34,6 @@ class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Log error to console in development - if (process.env.NODE_ENV === 'development') { - console.error('ErrorBoundary caught an error:', error, errorInfo); - } - this.setState({ error, errorInfo, @@ -59,52 +55,23 @@ class ErrorBoundary extends Component { } return ( -
-

Something went wrong

-

+

+

Something went wrong

+

An error occurred while rendering this component. Please try refreshing the page.

{process.env.NODE_ENV === 'development' && this.state.error && ( -
- +
+ Error Details (Development Only) -
+              
                 {this.state.error.toString()}
                 {this.state.errorInfo?.componentStack}
               
)} -
@@ -116,4 +83,3 @@ class ErrorBoundary extends Component { } export default ErrorBoundary; - diff --git a/frontend/src/components/ui/IntroModal.css b/frontend/src/components/ui/IntroModal.css new file mode 100644 index 0000000000000000000000000000000000000000..daa916dcefde26d3d0916fd195254efd7aac8e6f --- /dev/null +++ b/frontend/src/components/ui/IntroModal.css @@ -0,0 +1,268 @@ +/* ============================================ + INTRO MODAL - TOP LEFT ONBOARDING + ============================================ */ + +.intro-modal { + position: absolute; + top: 60px; + left: 20px; + width: 280px; + background: var(--bg-elevated, rgba(22, 27, 34, 0.98)); + border: 1px solid var(--border-medium); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + z-index: 1000; + font-family: var(--font-primary); + animation: introSlideIn 0.2s ease-out; +} + +@keyframes introSlideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.intro-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.intro-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.intro-close { + background: transparent; + border: none; + color: var(--text-tertiary); + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.intro-close:hover { + color: var(--text-primary); +} + +.intro-content { + padding: 12px; +} + +.intro-desc { + margin: 0 0 12px 0; + font-size: 0.75rem; + color: var(--text-primary); + line-height: 1.5; +} + +.intro-desc strong { + color: var(--accent-blue); + font-weight: 600; +} + +.intro-desc-sub { + display: block; + margin-top: 4px; + font-size: 0.68rem; + color: var(--text-tertiary); + font-style: italic; +} + +.intro-section { + margin-bottom: 10px; +} + +.intro-section:last-child { + margin-bottom: 0; +} + +.intro-section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: 6px; +} + +.intro-section-title svg { + color: var(--accent-blue); +} + +.intro-list { + margin: 0; + padding: 0; + list-style: none; +} + +.intro-list li { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.7rem; + color: var(--text-secondary); + padding: 2px 0; +} + +.intro-list li strong { + color: var(--text-primary); + font-weight: 500; +} + +.intro-list.compact li { + padding: 1px 0; +} + +.intro-list.inline { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.intro-list.inline li { + padding: 0; + gap: 4px; +} + +.intro-detail { + color: var(--text-tertiary); + font-size: 0.65rem; + margin-left: auto; +} + +.intro-list li code { + background: var(--bg-tertiary); + padding: 1px 4px; + font-size: 0.65rem; + font-family: var(--font-mono); + color: var(--text-primary); +} + +.intro-list li kbd { + display: inline-block; + padding: 1px 5px; + font-size: 0.6rem; + font-family: var(--font-mono); + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + color: var(--text-primary); +} + +.intro-inline-icon { + color: #f59e0b; + vertical-align: middle; +} + +.intro-color { + width: 10px; + height: 10px; + flex-shrink: 0; +} + +.intro-color.family { + background: linear-gradient(135deg, #22d3ee 0%, #facc15 50%, #f472b6 100%); +} + +.intro-color.library { + background: linear-gradient(135deg, #60a5fa 0%, #4ade80 50%, #fb923c 100%); +} + +.intro-color.task { + background: linear-gradient(135deg, #4ade80 0%, #22d3ee 50%, #c084fc 100%); +} + +.intro-text { + margin: 0; + font-size: 0.7rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.intro-text kbd { + display: inline-block; + padding: 1px 5px; + font-size: 0.65rem; + font-family: var(--font-mono); + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + color: var(--text-primary); +} + +.intro-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-top: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.intro-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.65rem; + color: var(--text-tertiary); + cursor: pointer; +} + +.intro-checkbox input { + width: 12px; + height: 12px; + margin: 0; + cursor: pointer; +} + +.intro-dismiss { + padding: 5px 12px; + background: var(--accent-blue); + border: none; + color: #ffffff; + font-size: 0.7rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.intro-dismiss:hover { + background: #3b82f6; +} + +/* Light theme */ +[data-theme="light"] .intro-modal { + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); +} + +/* Responsive - hide on small screens */ +@media (max-width: 768px) { + .intro-modal { + display: none; + } +} + +/* Smaller screens - adjust position */ +@media (max-width: 1200px) { + .intro-modal { + width: 260px; + } +} + diff --git a/frontend/src/components/ui/IntroModal.tsx b/frontend/src/components/ui/IntroModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1f613c3ea3ae349f32e7d9dcb95d864297d8711f --- /dev/null +++ b/frontend/src/components/ui/IntroModal.tsx @@ -0,0 +1,121 @@ +/** + * Intro Modal - Brief onboarding guide for the dashboard + */ +import React, { useState, useEffect } from 'react'; +import { X, Palette, Maximize2, Search, Move3D, Sparkles } from 'lucide-react'; +import './IntroModal.css'; + +interface IntroModalProps { + onClose: () => void; +} + +export default function IntroModal({ onClose }: IntroModalProps) { + const [dontShowAgain, setDontShowAgain] = useState(false); + + const handleClose = () => { + if (dontShowAgain) { + localStorage.setItem('hf-intro-dismissed', 'true'); + } + onClose(); + }; + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [dontShowAgain]); + + return ( +
+
+ Quick Guide + +
+ +
+

+ Visualizing 2M+ ML models from Hugging Face by text embeddings. +
+ Each point represents a model positioned by semantic similarity. +

+ +
+
+ + Colors +
+
    +
  • + + Family + Lineage depth +
  • +
  • + + Library + ML framework +
  • +
  • + + Task + Model type +
  • +
+
+ +
+
+ + Size +
+

Larger points = more downloads/likes

+
+ +
+
+ + Controls +
+
    +
  • Drag rotate
  • +
  • Scroll zoom
  • +
  • Click select
  • +
+
+ +
+
+ + Search +
+
    +
  • ⌘K open search
  • +
  • Fuzzy: lama finds llama
  • +
+
+
+ +
+ + +
+
+ ); +} + diff --git a/frontend/src/components/ui/LiveModelCount.css b/frontend/src/components/ui/LiveModelCount.css index 0a0bfdd8e5a5fb3fcb37a8ba8fcaf54597d4020e..372158e2212b573d18fff3464d9f96de1adb40f1 100644 --- a/frontend/src/components/ui/LiveModelCount.css +++ b/frontend/src/components/ui/LiveModelCount.css @@ -68,10 +68,44 @@ opacity: 0.9; } +.count-loading-full { + text-align: center; + padding: 1rem; + color: var(--text-tertiary); +} + .count-error { color: #ffcccc; } +.count-error-full { + padding: 1rem; + background: var(--bg-secondary); + border: 1px solid var(--border-light); + color: var(--text-secondary); + text-align: center; +} + +.count-error-message { + margin-bottom: 0.5rem; + color: #ef4444; +} + +.count-retry-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-medium); + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary); + transition: all var(--transition-base); +} + +.count-retry-btn:hover { + background: var(--bg-secondary); + border-color: var(--accent-blue); +} + /* Full version (for sidebar) - matches sidebar-section style */ .live-model-count-full { background: white; diff --git a/frontend/src/components/ui/LiveModelCount.tsx b/frontend/src/components/ui/LiveModelCount.tsx index 427dfee57fecea8df0462b9505b31712ea00b0ec..8cddafc1175abcdd0db0e96663c9a331aeb420d4 100644 --- a/frontend/src/components/ui/LiveModelCount.tsx +++ b/frontend/src/components/ui/LiveModelCount.tsx @@ -91,25 +91,15 @@ export default function LiveModelCount({ compact = true }: { compact?: boolean } ) : error && !currentCount ? (
Error
) : currentCount ? ( - <> -
- Live Models: - {formatNumber(currentCount.total_models)} - {lastUpdate && ( - - {getTimeAgo(lastUpdate)} - - )} -
- - +
+ Live Models: + {formatNumber(currentCount.total_models)} + {lastUpdate && ( + + {getTimeAgo(lastUpdate)} + + )} +
) : null}
); @@ -120,39 +110,13 @@ export default function LiveModelCount({ compact = true }: { compact?: boolean }

Live Model Count

-
{loading && !currentCount ? ( -
Loading...
+
Loading...
) : error && !currentCount ? ( -
-
Error: {error}
-
diff --git a/frontend/src/components/ui/LiveModelCounter.css b/frontend/src/components/ui/LiveModelCounter.css new file mode 100644 index 0000000000000000000000000000000000000000..0094a45fd4c109d8a5e238bc23b50b0559efc252 --- /dev/null +++ b/frontend/src/components/ui/LiveModelCounter.css @@ -0,0 +1,165 @@ +/* ============================================ + LIVE MODEL COUNTER - TOP LEFT DISPLAY + ============================================ */ + +.live-model-counter { + position: absolute; + top: 20px; + left: 20px; + background: var(--bg-elevated, rgba(26, 26, 26, 0.95)); + border: 1px solid var(--border-medium, rgba(255, 255, 255, 0.15)); + padding: 12px 16px; + min-width: 200px; + z-index: 500; + font-family: var(--font-primary); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + transition: all var(--transition-base); +} + +.live-model-counter:hover { + border-color: var(--accent-blue); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); +} + +/* Pulse animation when new models detected */ +.live-model-counter.pulse { + animation: counterPulse 0.6s ease-out; +} + +@keyframes counterPulse { + 0% { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-color: var(--border-medium); + } + 50% { + box-shadow: 0 0 20px rgba(74, 144, 226, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3); + border-color: var(--accent-blue); + } + 100% { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-color: var(--border-medium); + } +} + +/* Main row with icon, value, and refresh */ +.counter-main { + display: flex; + align-items: center; + gap: 10px; +} + +.counter-content { + flex: 1; +} + +.counter-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #ffffff); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + line-height: 1.2; +} + +.counter-label { + font-size: 0.7rem; + color: var(--text-tertiary, rgba(255, 255, 255, 0.6)); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; +} + +/* Growth stats */ +.counter-growth { + display: flex; + align-items: center; + gap: 4px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-light); + font-size: 0.75rem; + color: #10b981; +} + +.counter-growth svg { + flex-shrink: 0; +} + +/* New models indicator */ +.counter-new-models { + margin-top: 6px; + font-size: 0.7rem; + color: var(--accent-blue); + font-weight: 500; +} + +/* Timestamp */ +.counter-timestamp { + margin-top: 6px; + font-size: 0.65rem; + color: var(--text-tertiary); + font-style: italic; +} + +/* Loading state */ +.counter-loading { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.counter-loading .spin { + animation: spin 1s linear infinite; +} + +/* Error state */ +.live-model-counter.counter-error { + border-color: #ef4444; + color: #ef4444; +} + +/* Light theme adjustments */ +[data-theme="light"] .live-model-counter { + background: rgba(255, 255, 255, 0.95); + border-color: var(--border-medium); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .counter-value { + color: var(--text-primary); +} + +[data-theme="light"] .counter-label { + color: var(--text-secondary); +} + +/* Responsive - hide on very small screens */ +@media (max-width: 480px) { + .live-model-counter { + top: 10px; + left: 10px; + padding: 8px 12px; + min-width: 160px; + } + + .counter-value { + font-size: 1.2rem; + } + + .counter-growth, + .counter-new-models, + .counter-timestamp { + display: none; + } +} + +/* Responsive - smaller on mobile */ +@media (max-width: 768px) { + .live-model-counter { + left: 10px; + top: 10px; + } +} + diff --git a/frontend/src/components/ui/LiveModelCounter.tsx b/frontend/src/components/ui/LiveModelCounter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9aa076d175468d6ac9517d10438000ce80a543c9 --- /dev/null +++ b/frontend/src/components/ui/LiveModelCounter.tsx @@ -0,0 +1,226 @@ +/** + * Live model counter component for bottom-left display. + * Shows real-time model count from Hugging Face Hub with pulse animation. + * Supports continual updates via polling. + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { TrendingUp, RefreshCw } from 'lucide-react'; +import { API_BASE } from '../../config/api'; +import './LiveModelCounter.css'; + +interface ModelCountData { + total_models: number; + timestamp: string; + source?: string; + models_by_library?: Record; + models_by_pipeline?: Record; +} + +interface GrowthStats { + daily_growth_avg?: number; + growth_rate_percent?: number; + total_growth?: number; + period_days?: number; +} + +interface LiveModelCounterProps { + pollInterval?: number; // in milliseconds, default 60 seconds + showGrowth?: boolean; + onNewModelsDetected?: (count: number, previousCount: number) => void; +} + +export default function LiveModelCounter({ + pollInterval = 60000, + showGrowth = true, + onNewModelsDetected, +}: LiveModelCounterProps) { + const [currentCount, setCurrentCount] = useState(null); + const [previousCount, setPreviousCount] = useState(null); + const [growthStats, setGrowthStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [newModelsAdded, setNewModelsAdded] = useState(0); + const [showPulse, setShowPulse] = useState(false); + + const fetchCurrentCount = useCallback(async (isManual = false) => { + if (isManual) setIsRefreshing(true); + + try { + // Try primary endpoint first + let data: ModelCountData | null = null; + + try { + const response = await fetch( + `${API_BASE}/api/model-count/current?use_models_page=true&use_cache=${!isManual}` + ); + if (response.ok) { + data = await response.json(); + } + } catch { + // Primary endpoint failed, will try fallback + } + + // Fallback to stats endpoint + if (!data || !data.total_models) { + const statsResponse = await fetch(`${API_BASE}/api/stats`); + if (statsResponse.ok) { + const statsData = await statsResponse.json(); + if (statsData.total_models) { + data = { + total_models: statsData.total_models, + timestamp: new Date().toISOString(), + source: 'stats' + }; + } + } + } + + if (!data || !data.total_models) { + throw new Error('No model count available'); + } + + // Check if new models were added + if (currentCount && data.total_models > currentCount.total_models) { + const newCount = data.total_models - currentCount.total_models; + setNewModelsAdded(prev => prev + newCount); + setShowPulse(true); + setTimeout(() => setShowPulse(false), 2000); + + if (onNewModelsDetected) { + onNewModelsDetected(data.total_models, currentCount.total_models); + } + } + + setPreviousCount(currentCount?.total_models || null); + setCurrentCount(data); + setLastUpdate(new Date()); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + setIsRefreshing(false); + } + }, [currentCount, onNewModelsDetected]); + + const fetchGrowthStats = useCallback(async () => { + try { + const response = await fetch(`${API_BASE}/api/model-count/growth?days=7`); + if (response.ok) { + const data = await response.json(); + setGrowthStats(data); + } + } catch { + // Silently fail - growth stats are optional + } + }, []); + + // Initial fetch + useEffect(() => { + fetchCurrentCount(); + if (showGrowth) { + fetchGrowthStats(); + } + }, []); + + // Polling interval + useEffect(() => { + const interval = setInterval(() => { + fetchCurrentCount(); + }, pollInterval); + + return () => clearInterval(interval); + }, [pollInterval, fetchCurrentCount]); + + // Refresh growth stats less frequently + useEffect(() => { + if (!showGrowth) return; + + const growthInterval = setInterval(() => { + fetchGrowthStats(); + }, 5 * 60 * 1000); // Every 5 minutes + + return () => clearInterval(growthInterval); + }, [showGrowth, fetchGrowthStats]); + + const formatNumber = (num: number): string => { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(2)}M`; + } + return new Intl.NumberFormat('en-US').format(num); + }; + + const formatLargeNumber = (num: number): string => { + return new Intl.NumberFormat('en-US').format(num); + }; + + const getTimeAgo = (date: Date): string => { + const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + }; + + if (loading && !currentCount) { + return ( +
+
+ + Loading... +
+
+ ); + } + + if (error && !currentCount) { + return ( +
+ Error loading count +
+ ); + } + + return ( +
+
+
+
+ {currentCount ? formatLargeNumber(currentCount.total_models) : '—'} +
+
+ Models on Hugging Face +
+
+
+ + {showGrowth && growthStats && growthStats.daily_growth_avg && ( +
+ + +{Math.round(growthStats.daily_growth_avg).toLocaleString()}/day +
+ )} + + {newModelsAdded > 0 && ( +
+ +{newModelsAdded.toLocaleString()} new this session +
+ )} + + {lastUpdate && ( +
+ Updated {getTimeAgo(lastUpdate)} +
+ )} +
+ ); +} + diff --git a/frontend/src/components/ui/LoadingProgress.css b/frontend/src/components/ui/LoadingProgress.css new file mode 100644 index 0000000000000000000000000000000000000000..cdee42758ada78737a79f59e07ce0a86901bb7ba --- /dev/null +++ b/frontend/src/components/ui/LoadingProgress.css @@ -0,0 +1,63 @@ +.loading-progress { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 400px; + padding: 2rem; +} + +.loading-progress-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + max-width: 500px; + text-align: center; +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-light, #e0e0e0); + border-top-color: var(--accent-primary, #4a90e2); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-message { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary, #333); +} + +.loading-submessage { + font-size: 0.9rem; + color: var(--text-secondary, #666); + font-weight: 400; +} + +.loading-bar-container { + width: 100%; + height: 8px; + background: var(--bg-tertiary, #f5f5f5); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; + overflow: hidden; + margin-top: 0.5rem; +} + +.loading-bar { + height: 100%; + background: var(--accent-primary, #4a90e2); + transition: width 0.3s ease; + border-radius: 0; +} + diff --git a/frontend/src/components/ui/LoadingProgress.tsx b/frontend/src/components/ui/LoadingProgress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..43e3a8f183704385a3d47bccb61968f7467551a5 --- /dev/null +++ b/frontend/src/components/ui/LoadingProgress.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import './LoadingProgress.css'; + +interface LoadingProgressProps { + message?: string; + progress?: number; // 0-100 + subMessage?: string; +} + +export default function LoadingProgress({ + message = 'Loading models...', + progress, + subMessage +}: LoadingProgressProps) { + return ( +
+
+
+
{message}
+ {subMessage && ( +
{subMessage}
+ )} + {progress !== undefined && ( +
+
+
+ )} +
+
+ ); +} + diff --git a/frontend/src/components/ui/ModelCountTracker.tsx b/frontend/src/components/ui/ModelCountTracker.tsx index 4a29b19437d48511b37fabd628bbaece34fc7373..f90b01b1616135ad24e4275b6fbd748e93498eff 100644 --- a/frontend/src/components/ui/ModelCountTracker.tsx +++ b/frontend/src/components/ui/ModelCountTracker.tsx @@ -48,8 +48,8 @@ export default function ModelCountTracker() { if (!response.ok) throw new Error('Failed to fetch growth stats'); const data = await response.json(); setGrowthStats(data); - } catch (err) { - console.error('Error fetching growth stats:', err); + } catch { + // Silent error handling } }; diff --git a/frontend/src/components/ui/ModelPopup.css b/frontend/src/components/ui/ModelPopup.css new file mode 100644 index 0000000000000000000000000000000000000000..d217e75f567543628bd4cd1a489d282fea7b70b8 --- /dev/null +++ b/frontend/src/components/ui/ModelPopup.css @@ -0,0 +1,317 @@ +/* ============================================ + MODEL POPUP - BOTTOM LEFT COMPACT VIEW + ============================================ */ + +.model-popup { + position: absolute; + bottom: 20px; + left: 20px; + width: 320px; + max-height: calc(100% - 80px); + background: var(--bg-elevated, rgba(26, 26, 26, 0.98)); + border: 1px solid var(--border-medium, rgba(255, 255, 255, 0.15)); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + z-index: 1000; + display: flex; + flex-direction: column; + font-family: var(--font-primary); + animation: popupSlideIn 0.2s ease-out; +} + +@keyframes popupSlideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header */ +.popup-header { + padding: 12px 12px 10px; + border-bottom: 1px solid var(--border-light); +} + +.popup-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.popup-title-wrapper { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.popup-title { + margin: 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + line-height: 1.3; + word-break: break-word; +} + +.popup-base-badge { + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 2px 6px; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #10b981; + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.popup-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* Bookmark Button */ +.popup-bookmark-btn { + background: transparent; + border: none; + color: var(--text-tertiary); + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); +} + +.popup-bookmark-btn:hover { + color: #f59e0b; +} + +.popup-bookmark-btn.active { + color: #f59e0b; +} + +.popup-close-btn { + background: transparent; + border: 1px solid var(--border-light); + color: var(--text-tertiary); + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); +} + +.popup-close-btn:hover { + color: #ef4444; + border-color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} + +/* Content */ +.popup-content { + padding: 12px; + overflow-y: auto; + flex: 1; +} + +/* Stats Row */ +.popup-stats { + display: flex; + gap: 12px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-light); +} + +.popup-stat { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.8rem; + color: var(--text-primary); +} + +.popup-stat svg { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.popup-stat.trending { + color: #10b981; +} + +.popup-stat.trending svg { + color: #10b981; +} + +/* Info Grid */ +.popup-info-grid { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.popup-info-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; +} + +.popup-info-item svg { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.popup-info-label { + color: var(--text-tertiary); + min-width: 50px; +} + +.popup-info-value { + color: var(--text-primary); + font-weight: 500; +} + +/* Lineage */ +.popup-lineage { + margin-bottom: 12px; + padding: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-light); +} + +.popup-lineage-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + margin-bottom: 6px; +} + +.popup-lineage-path { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 0.75rem; +} + +.popup-lineage-item { + color: var(--text-secondary); + padding: 2px 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.popup-lineage-item.current { + color: var(--accent-blue); + border-color: var(--accent-blue); + background: rgba(74, 144, 226, 0.1); +} + +.popup-lineage-separator { + color: var(--text-tertiary); + font-size: 0.7rem; +} + +.popup-lineage-loading { + color: var(--text-tertiary); + font-style: italic; + font-size: 0.75rem; +} + +.popup-lineage-base { + display: flex; + align-items: center; + gap: 5px; + color: #10b981; + font-weight: 500; + font-size: 0.75rem; +} + +.popup-lineage-base svg { + color: #10b981; +} + +/* Tags */ +.popup-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.popup-tag { + font-size: 0.65rem; + padding: 2px 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + color: var(--text-secondary); +} + +.popup-tag-more { + font-size: 0.65rem; + padding: 2px 6px; + color: var(--text-tertiary); + font-style: italic; +} + +/* Footer */ +.popup-footer { + padding: 10px 12px; + border-top: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.popup-hf-link { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + background: var(--accent-blue); + color: #ffffff; + text-decoration: none; + font-size: 0.8rem; + font-weight: 500; + transition: all var(--transition-base); +} + +.popup-hf-link svg { + flex-shrink: 0; +} + +.popup-hf-link:hover { + background: #3b82f6; +} + +/* Light theme */ +[data-theme="light"] .model-popup { + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); +} + +/* Responsive */ +@media (max-width: 480px) { + .model-popup { + width: calc(100% - 40px); + max-width: 320px; + } +} + diff --git a/frontend/src/components/ui/ModelPopup.tsx b/frontend/src/components/ui/ModelPopup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..507ae6dc8ac28a819f4306a2a2263dbff06d8b7a --- /dev/null +++ b/frontend/src/components/ui/ModelPopup.tsx @@ -0,0 +1,243 @@ +/** + * Popup component for displaying model information in the bottom left. + * Replaces the full-screen modal with a compact, persistent popup. + */ +import React, { useState, useEffect } from 'react'; +import { X, ArrowUpRight, Bookmark, Download, Heart, TrendingUp, GitBranch, Tag, Layers, Box } from 'lucide-react'; +import { ModelPoint } from '../../types'; +import { getHuggingFaceUrl } from '../../utils/api/hfUrl'; +import { API_BASE } from '../../config/api'; +import './ModelPopup.css'; + +interface ModelPopupProps { + model: ModelPoint | null; + isOpen: boolean; + onClose: () => void; + onBookmark?: (modelId: string) => void; + isBookmarked?: boolean; +} + +export default function ModelPopup({ + model, + isOpen, + onClose, + onBookmark, + isBookmarked = false, +}: ModelPopupProps) { + const [lineagePath, setLineagePath] = useState([]); + const [lineageLoading, setLineageLoading] = useState(false); + + // Fetch lineage path when model changes + useEffect(() => { + if (!isOpen || !model) { + setLineagePath([]); + return; + } + + const fetchLineage = async () => { + setLineageLoading(true); + try { + const response = await fetch(`${API_BASE}/api/family/path/${encodeURIComponent(model.model_id)}`); + if (response.ok) { + const data = await response.json(); + setLineagePath(data.path || []); + } else { + setLineagePath([]); + } + } catch { + setLineagePath([]); + } finally { + setLineageLoading(false); + } + }; + + fetchLineage(); + }, [model?.model_id, isOpen]); + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen || !model) return null; + + const formatNumber = (num: number | null): string => { + if (num === null || num === undefined) return '—'; + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; + return num.toLocaleString(); + }; + + const formatDate = (dateString: string | null): string => { + if (!dateString) return '—'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch { + return dateString; + } + }; + + return ( +
+ {/* Header */} +
+
+
+

+ {model.model_id} +

+ {!model.parent_model && (model.family_depth === 0 || model.family_depth === null) && ( + BASE + )} +
+
+ {onBookmark && ( + + )} + +
+
+
+ + {/* Content */} +
+ {/* Stats Row */} +
+
+ + {formatNumber(model.downloads)} +
+
+ + {formatNumber(model.likes)} +
+ {model.trending_score !== null && model.trending_score > 0 && ( +
+ + {model.trending_score.toFixed(1)} +
+ )} +
+ + {/* Info Grid */} +
+ {model.library_name && ( +
+ + Library + {model.library_name} +
+ )} + {model.pipeline_tag && ( +
+ + Task + {model.pipeline_tag} +
+ )} + {model.created_at && ( +
+ Created + {formatDate(model.created_at)} +
+ )} + {model.family_depth !== null && model.family_depth !== undefined && ( +
1 ? 's' : ''} from the root model`}`}> + + Depth + {model.family_depth} +
+ )} +
+ + {/* Lineage */} +
+
Lineage
+
+ {lineageLoading ? ( + Loading... + ) : lineagePath.length > 1 ? ( + lineagePath.map((pathModel, idx) => ( + + + {pathModel.split('/').pop()} + + {idx < lineagePath.length - 1 && ( + > + )} + + )) + ) : model.parent_model ? ( + <> + + {model.parent_model.split('/').pop()} + + > + + {model.model_id.split('/').pop()} + + + ) : ( + + + Root model + + )} +
+
+ + {/* Tags */} + {model.tags && ( +
+ {(typeof model.tags === 'string' ? model.tags.split(',') : []) + .slice(0, 6) + .map((tag, idx) => ( + {tag.trim()} + ))} + {(typeof model.tags === 'string' ? model.tags.split(',') : []).length > 6 && ( + + +{(typeof model.tags === 'string' ? model.tags.split(',') : []).length - 6} + + )} +
+ )} +
+ + {/* Footer */} + +
+ ); +} + diff --git a/frontend/src/components/ui/ModelTooltip.css b/frontend/src/components/ui/ModelTooltip.css new file mode 100644 index 0000000000000000000000000000000000000000..1035c3250490a2b09a1686af99d17735f5f40ac7 --- /dev/null +++ b/frontend/src/components/ui/ModelTooltip.css @@ -0,0 +1,71 @@ +/* ============================================ + MODEL TOOLTIP + ============================================ */ + +.model-tooltip { + position: fixed; + background: rgba(20, 20, 20, 0.98); + padding: 12px 16px; + font-size: 13px; + max-width: 350px; + z-index: 10000; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.model-tooltip-title { + font-weight: 600; + margin-bottom: 8px; + font-size: 14px; + color: #fff; +} + +.model-tooltip-content { + margin-bottom: 6px; + font-size: 12px; + color: #d0d0d0; +} + +.model-tooltip-row { + margin-bottom: 4px; +} + +.model-tooltip-row-small { + margin-bottom: 4px; + font-size: 11px; + color: #aaa; +} + +.model-tooltip-label { + color: #888; +} + +.model-tooltip-label-spaced { + color: #888; + margin-left: 8px; +} + +.model-tooltip-loading { + font-size: 11px; + color: #888; + font-style: italic; + margin-top: 8px; +} + +.model-tooltip-description { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 12px; + color: #e0e0e0; + line-height: 1.4; +} + +.model-tooltip-hint { + margin-top: 8px; + font-size: 11px; + color: #888; + font-style: italic; +} + diff --git a/frontend/src/components/ui/ModelTooltip.tsx b/frontend/src/components/ui/ModelTooltip.tsx index 78f409b72345ae4a172d3a66c163d51c0be4ba38..d180e699f3d724cdcfec7095026930ed5418f7f9 100644 --- a/frontend/src/components/ui/ModelTooltip.tsx +++ b/frontend/src/components/ui/ModelTooltip.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react'; import { ModelPoint } from '../../types'; import { getHuggingFaceApiUrl } from '../../utils/api/hfUrl'; +import './ModelTooltip.css'; interface ModelTooltipProps { model: ModelPoint | null; @@ -21,7 +22,7 @@ function formatDate(dateString: string | null): string { if (!dateString) return ''; try { const date = new Date(dateString); - if (isNaN(date.getTime())) return dateString; // Return original if invalid + if (isNaN(date.getTime())) return dateString; return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', @@ -42,19 +43,15 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP return; } - // Check cache first if (cache.has(model.model_id)) { setDetails({ description: cache.get(model.model_id) }); return; } - // Fetch model description from Hugging Face API setDetails({ loading: true }); const fetchDescription = async () => { try { - // Try to get description from Hugging Face API - // Use HF token if available (from env or localStorage) const hfToken = process.env.REACT_APP_HF_TOKEN || (typeof window !== 'undefined' ? localStorage.getItem('HF_TOKEN') : null); @@ -78,7 +75,6 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP null; if (description) { - // Cache the description const newCache = new Map(cache); newCache.set(model.model_id, description); setCache(newCache); @@ -89,8 +85,7 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP } else { setDetails({}); } - } catch (error) { - console.error('Error fetching model description:', error); + } catch { setDetails({}); } }; @@ -109,81 +104,58 @@ export default function ModelTooltip({ model, position, visible }: ModelTooltipP return (
-
+
{model.model_id}
-
+
{model.library_name && ( -
- Library: {model.library_name} +
+ Library: {model.library_name}
)} {model.pipeline_tag && ( -
- Task: {model.pipeline_tag} +
+ Task: {model.pipeline_tag}
)} -
- Downloads: {model.downloads.toLocaleString()} | - Likes: {model.likes.toLocaleString()} +
+ Downloads: {model.downloads.toLocaleString()} | + Likes: {model.likes.toLocaleString()}
{model.created_at && ( -
- Created: {formatDate(model.created_at)} +
+ Created: {formatDate(model.created_at)}
)} {model.parent_model && ( -
- Parent: {model.parent_model} +
+ Parent: {model.parent_model}
)}
{details.loading && ( -
+
Loading description...
)} {truncatedDescription && ( -
+
{truncatedDescription}
)} -
+
Click for details
); } - diff --git a/frontend/src/components/ui/VirtualSearchResults.css b/frontend/src/components/ui/VirtualSearchResults.css new file mode 100644 index 0000000000000000000000000000000000000000..7ee20038025fd961c5bc9fc9b200385602adedba --- /dev/null +++ b/frontend/src/components/ui/VirtualSearchResults.css @@ -0,0 +1,39 @@ +/* ============================================ + VIRTUAL SEARCH RESULTS + ============================================ */ + +.virtual-search-result { + cursor: pointer; + padding: 8px 12px; + background-color: transparent; + border-bottom: 1px solid var(--border-light); + transition: background-color var(--transition-base); +} + +.virtual-search-result:hover { + background-color: var(--bg-secondary); +} + +.virtual-search-result.selected { + background-color: var(--bg-tertiary); +} + +.virtual-search-result-title { + font-weight: 500; + color: var(--text-primary); +} + +.virtual-search-result-meta { + font-size: 0.875rem; + opacity: 0.7; + margin-top: 2px; + color: var(--text-secondary); +} + +.virtual-search-result-stats { + font-size: 0.75rem; + opacity: 0.6; + margin-top: 2px; + color: var(--text-tertiary); +} + diff --git a/frontend/src/components/ui/VirtualSearchResults.tsx b/frontend/src/components/ui/VirtualSearchResults.tsx index e9a2c3ec995d98a46b0de31282d0f95ee2d16513..e89336ab6fa7e5a63bd76e31c6d3b924ef638c92 100644 --- a/frontend/src/components/ui/VirtualSearchResults.tsx +++ b/frontend/src/components/ui/VirtualSearchResults.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { FixedSizeList as List } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { SearchResult } from '../../types'; +import './VirtualSearchResults.css'; interface VirtualSearchResultsProps { results: SearchResult[]; @@ -24,33 +25,18 @@ export const VirtualSearchResults: React.FC = ({ return (
onSelect(result)} - onMouseEnter={(e) => { - if (!isSelected) { - e.currentTarget.style.backgroundColor = 'var(--hover-color)'; - } - }} - onMouseLeave={(e) => { - if (!isSelected) { - e.currentTarget.style.backgroundColor = 'transparent'; - } - }} > -
{result.model_id}
+
{result.model_id}
{result.library_name && ( -
+
{result.library_name} {result.pipeline_tag && `• ${result.pipeline_tag}`}
)} {(result.downloads || result.likes) && ( -
+
{result.downloads?.toLocaleString()} downloads • {result.likes?.toLocaleString()} likes
)} diff --git a/frontend/src/components/visualizations/AdoptionCurve.css b/frontend/src/components/visualizations/AdoptionCurve.css new file mode 100644 index 0000000000000000000000000000000000000000..a8bb85d54ea378920c94883dae2fa2a57dbbaf72 --- /dev/null +++ b/frontend/src/components/visualizations/AdoptionCurve.css @@ -0,0 +1,138 @@ +.adoption-curve-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + position: relative; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; +} + +.adoption-curve-empty { + padding: 3rem; + text-align: center; + color: var(--text-secondary, #666); + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; +} + +.adoption-tooltip { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tooltip-model-id { + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.25rem; + color: var(--text-primary, #1a1a1a); +} + +[data-theme="dark"] .tooltip-model-id { + color: #ffffff; +} + +.tooltip-date { + color: var(--text-secondary, #666666); + font-size: 0.8rem; +} + +[data-theme="dark"] .tooltip-date { + color: rgba(255, 255, 255, 0.8); +} + +.tooltip-stats { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border-light, #e8e8e8); + font-size: 0.8rem; +} + +[data-theme="dark"] .tooltip-stats { + border-top: 1px solid rgba(255, 255, 255, 0.2); +} + +.tooltip-family { + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.25rem; + color: var(--text-primary, #1a1a1a); +} + +[data-theme="dark"] .tooltip-family { + color: #ffffff; +} + +.adoption-legend { + position: absolute; + top: 20px; + right: 20px; + background: var(--bg-elevated, #ffffff); + border: 1px solid var(--border-medium, #d0d0d0); + border-radius: 8px; + padding: 1rem; + box-shadow: var(--shadow-lg, 0 2px 8px rgba(0, 0, 0, 0.12)); + z-index: 10; + min-width: 180px; + backdrop-filter: blur(10px); +} + +[data-theme="dark"] .adoption-legend { + background: rgba(20, 20, 20, 0.98); + border: 1px solid rgba(255, 255, 255, 0.25); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); +} + +.legend-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #666666); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-light, #e8e8e8); +} + +[data-theme="dark"] .legend-title { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.625rem; + font-size: 0.875rem; +} + +.legend-item:last-child { + margin-bottom: 0; +} + +.legend-color-line { + width: 20px; + height: 3px; + border-radius: 2px; + flex-shrink: 0; +} + +.legend-label { + color: var(--text-primary, #1a1a1a); + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-theme="dark"] .legend-label { + color: #ffffff; +} diff --git a/frontend/src/components/visualizations/AdoptionCurve.tsx b/frontend/src/components/visualizations/AdoptionCurve.tsx new file mode 100644 index 0000000000000000000000000000000000000000..78afba02d0bb3749e4b2406fb1d8545a07dd43fb --- /dev/null +++ b/frontend/src/components/visualizations/AdoptionCurve.tsx @@ -0,0 +1,447 @@ +import React, { useMemo, useCallback } from 'react'; +import { Group } from '@visx/group'; +import { AreaClosed, LinePath } from '@visx/shape'; +import { AxisLeft, AxisBottom } from '@visx/axis'; +import { scaleTime, scaleLinear } from '@visx/scale'; +import { useTooltip, TooltipWithBounds, defaultStyles } from '@visx/tooltip'; +import { localPoint } from '@visx/event'; +import { bisector } from 'd3-array'; +import './AdoptionCurve.css'; + +export interface AdoptionDataPoint { + date: Date; + downloads: number; + modelId: string; +} + +interface ProcessedAdoptionDataPoint extends AdoptionDataPoint { + cumulativeDownloads: number; +} + +interface FamilyAdoptionData { + family: string; + data: AdoptionDataPoint[]; + color?: string; +} + +interface AdoptionCurveProps { + data: AdoptionDataPoint[]; + selectedModel?: string; + width?: number; + height?: number; + margin?: { top: number; right: number; bottom: number; left: number }; + // Comparison mode: multiple families + families?: FamilyAdoptionData[]; +} + +const defaultMargin = { top: 20, right: 20, bottom: 60, left: 80 }; + +const bisectDate = bisector((d) => d.date).left; + +// Color palette for multiple families +const FAMILY_COLORS = [ + '#3b82f6', // blue + '#ef4444', // red + '#10b981', // green + '#f59e0b', // amber + '#8b5cf6', // purple + '#ec4899', // pink + '#06b6d4', // cyan + '#f97316', // orange +]; + +export default function AdoptionCurve({ + data, + selectedModel, + width = 800, + height = 400, + margin = defaultMargin, + families, +}: AdoptionCurveProps) { + const { + tooltipData, + tooltipLeft, + tooltipTop, + tooltipOpen, + showTooltip, + hideTooltip, + } = useTooltip(); + + // Process data: calculate cumulative downloads for single family mode + const processedData: ProcessedAdoptionDataPoint[] = useMemo(() => { + if (families && families.length > 0) return []; // Use families data instead + + if (!data || data.length === 0) return []; + + const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime()); + let cumulative = 0; + + return sorted.map((point) => { + cumulative += point.downloads; + return { + ...point, + cumulativeDownloads: cumulative, + }; + }); + }, [data, families]); + + // Process multiple families for comparison mode + const processedFamilies = useMemo(() => { + if (!families || families.length === 0) return []; + + return families.map((family, idx) => { + const sorted = [...family.data].sort((a, b) => a.date.getTime() - b.date.getTime()); + let cumulative = 0; + + const processed = sorted.map((point) => { + cumulative += point.downloads; + return { + ...point, + cumulativeDownloads: cumulative, + }; + }); + + return { + family: family.family, + data: processed, + color: family.color || FAMILY_COLORS[idx % FAMILY_COLORS.length], + }; + }); + }, [families]); + + // Calculate scales - handle both single and multi-family modes + const allDates = useMemo(() => { + if (processedFamilies.length > 0) { + return processedFamilies.flatMap(f => f.data.map(d => d.date)); + } + return processedData.map(d => d.date); + }, [processedData, processedFamilies]); + + const allMaxDownloads = useMemo(() => { + if (processedFamilies.length > 0) { + return Math.max(...processedFamilies.flatMap(f => f.data.map(d => d.cumulativeDownloads))); + } + return Math.max(...processedData.map(d => d.cumulativeDownloads)); + }, [processedData, processedFamilies]); + + const xScale = useMemo(() => { + if (allDates.length === 0) { + return scaleTime({ + domain: [new Date(), new Date()], + range: [margin.left, width - margin.right], + }); + } + + return scaleTime({ + domain: [new Date(Math.min(...allDates.map(d => d.getTime()))), new Date(Math.max(...allDates.map(d => d.getTime())))], + range: [margin.left, width - margin.right], + }); + }, [allDates, width, margin]); + + const yScale = useMemo(() => { + if (allDates.length === 0) { + return scaleLinear({ + domain: [0, 1], + range: [height - margin.bottom, margin.top], + }); + } + + return scaleLinear({ + domain: [0, allMaxDownloads * 1.1], + range: [height - margin.bottom, margin.top], + }); + }, [allDates.length, allMaxDownloads, height, margin]); + + const isComparisonMode = processedFamilies.length > 0; + + // Handle mouse move for tooltip + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const coords = localPoint(event.currentTarget.ownerSVGElement!, event); + if (!coords) return; + + const x0 = xScale.invert(coords.x - margin.left); + const date = x0 instanceof Date ? x0 : new Date(x0); + + if (isComparisonMode) { + // Find closest point across all families + let closestPoint: ProcessedAdoptionDataPoint | null = null; + let closestFamily: string | null = null; + let minDistance = Infinity; + + processedFamilies.forEach((family) => { + const index = bisectDate(family.data, date, 1); + const a = family.data[index - 1]; + const b = family.data[index]; + + let point: ProcessedAdoptionDataPoint | null = null; + if (!b) { + point = a; + } else if (!a) { + point = b; + } else { + point = date.getTime() - a.date.getTime() > b.date.getTime() - date.getTime() ? b : a; + } + + if (point) { + const distance = Math.abs(point.date.getTime() - date.getTime()); + if (distance < minDistance) { + minDistance = distance; + closestPoint = point; + closestFamily = family.family; + } + } + }); + + if (closestPoint && closestFamily) { + const tooltipDataWithFamily = Object.assign({}, closestPoint, { family: closestFamily }); + showTooltip({ + tooltipData: tooltipDataWithFamily as any, + tooltipLeft: coords.x, + tooltipTop: coords.y, + }); + } + } else { + const index = bisectDate(processedData, date, 1); + const a = processedData[index - 1]; + const b = processedData[index]; + + let closestPoint: ProcessedAdoptionDataPoint | null = null; + if (!b) { + closestPoint = a; + } else if (!a) { + closestPoint = b; + } else { + closestPoint = date.getTime() - a.date.getTime() > b.date.getTime() - date.getTime() ? b : a; + } + + if (closestPoint) { + showTooltip({ + tooltipData: closestPoint, + tooltipLeft: coords.x, + tooltipTop: coords.y, + }); + } + } + }, + [processedData, processedFamilies, isComparisonMode, xScale, margin, showTooltip] + ); + + const hasData = isComparisonMode || processedData.length > 0; + + if (!hasData) { + return ( +
+

No adoption data available

+
+ ); + } + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + return ( +
+ {/* Legend for comparison mode - positioned nicely */} + {isComparisonMode && processedFamilies.length > 0 && ( +
+
Families
+ {processedFamilies.map((family) => ( +
+
+ {family.family} +
+ ))} +
+ )} + + + {isComparisonMode ? ( + // Multi-family comparison mode + <> + {processedFamilies.map((family, familyIdx) => { + const color = family.color; + const rgbaColor = color + '33'; // Add alpha for area + + return ( + + {/* Area under curve */} + + data={family.data} + x={(d) => xScale(d.date) ?? 0} + y={(d) => yScale(d.cumulativeDownloads) ?? 0} + yScale={yScale} + fill={rgbaColor} + stroke="none" + /> + {/* Line path */} + + data={family.data} + x={(d) => xScale(d.date) ?? 0} + y={(d) => yScale(d.cumulativeDownloads) ?? 0} + stroke={color} + strokeWidth={2.5} + strokeLinecap="round" + strokeLinejoin="round" + /> + + ); + })} + + ) : ( + // Single family mode + <> + {/* Area under curve */} + + data={processedData} + x={(d) => xScale(d.date) ?? 0} + y={(d) => yScale(d.cumulativeDownloads) ?? 0} + yScale={yScale} + fill="rgba(59, 130, 246, 0.2)" + stroke="none" + /> + {/* Line path */} + + data={processedData} + x={(d) => xScale(d.date) ?? 0} + y={(d) => yScale(d.cumulativeDownloads) ?? 0} + stroke="#3b82f6" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + /> + {/* Data points */} + {processedData.map((point, idx) => { + const x = xScale(point.date) ?? 0; + const y = yScale(point.cumulativeDownloads) ?? 0; + const isSelected = selectedModel === point.modelId; + + return ( + + ); + })} + + )} + + {/* X-axis */} + 520 ? 8 : 4} + stroke="#666" + tickStroke="#666" + tickLabelProps={() => ({ + fill: '#666', + fontSize: 10, + textAnchor: 'middle', + dy: -2, + })} + label="Date" + labelProps={{ + fill: '#333', + fontSize: 11, + textAnchor: 'middle', + dy: 40, + }} + /> + + {/* Y-axis */} + { + const num = Number(value); + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } else if (num >= 1000) { + return `${(num / 1000).toFixed(0)}K`; + } + return num.toString(); + }} + stroke="#666" + tickStroke="#666" + tickLabelProps={() => ({ + fill: '#666', + fontSize: 10, + textAnchor: 'end', + dx: -4, + dy: 3, + })} + label="Cumulative Downloads" + labelProps={{ + fill: '#333', + fontSize: 11, + textAnchor: 'middle', + transform: 'rotate(-90)', + dy: -50, + }} + /> + + {/* Invisible rect for mouse tracking */} + + + + + {/* Tooltip */} + {tooltipOpen && tooltipData && ( + +
+ {(tooltipData as any).family && ( +
f.family === (tooltipData as any).family)?.color }}> + {(tooltipData as any).family} +
+ )} +
{tooltipData.modelId}
+
+ {tooltipData.date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +
+
+
Downloads: {tooltipData.downloads.toLocaleString()}
+
Cumulative: {tooltipData.cumulativeDownloads.toLocaleString()}
+
+
+
+ )} + +
+ ); +} diff --git a/frontend/src/components/visualizations/DistanceHeatmap.css b/frontend/src/components/visualizations/DistanceHeatmap.css new file mode 100644 index 0000000000000000000000000000000000000000..084377ff742565208c5f8d1caa6e603ac89c9903 --- /dev/null +++ b/frontend/src/components/visualizations/DistanceHeatmap.css @@ -0,0 +1,39 @@ +/* ============================================ + DISTANCE HEATMAP + ============================================ */ + +.distance-heatmap-overlay { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 1; +} + +.distance-heatmap-info { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 8px 12px; + font-size: 11px; + font-family: var(--font-primary); +} + +.distance-heatmap-title { + font-weight: 600; + margin-bottom: 4px; +} + +.distance-heatmap-detail { + font-size: 10px; + opacity: 0.9; +} + +.distance-heatmap-range { + font-size: 10px; + opacity: 0.8; + margin-top: 4px; +} + diff --git a/frontend/src/components/visualizations/DistanceHeatmap.tsx b/frontend/src/components/visualizations/DistanceHeatmap.tsx index dae21d6869ee7b7abafb3ce55a513db187981b1f..27f4c9bfe7613cad13669ef43e9a1c1dbfadbcd5 100644 --- a/frontend/src/components/visualizations/DistanceHeatmap.tsx +++ b/frontend/src/components/visualizations/DistanceHeatmap.tsx @@ -4,6 +4,7 @@ */ import React, { useMemo } from 'react'; import { ModelPoint } from '../../types'; +import './DistanceHeatmap.css'; interface DistanceHeatmapProps { data: ModelPoint[]; @@ -18,7 +19,6 @@ export default function DistanceHeatmap({ selectedModel, width, height, - opacity = 0.3 }: DistanceHeatmapProps) { const distances = useMemo(() => { if (!selectedModel) return null; @@ -45,39 +45,18 @@ export default function DistanceHeatmap({ return (
-
-
Distance Heatmap
-
+
+
Distance Heatmap
+
Showing distance from: {selectedModel.model_id}
-
+
Range: {distances.minDist.toFixed(2)} - {distances.maxDist.toFixed(2)}
); } - - diff --git a/frontend/src/components/visualizations/MiniMap.css b/frontend/src/components/visualizations/MiniMap.css new file mode 100644 index 0000000000000000000000000000000000000000..94e7fbcbc08fc61571da047fd33af7032023d191 --- /dev/null +++ b/frontend/src/components/visualizations/MiniMap.css @@ -0,0 +1,227 @@ +/* ============================================ + MINI-MAP / OVERVIEW MAP + Best practices from GIS, embedding explorers, and visualization tools + ============================================ */ + +/* CSS Custom Properties for theming */ +:root { + --minimap-bg: #0d1117; + --minimap-header: #161b22; + --minimap-footer: #161b22; + --minimap-border: #30363d; + --minimap-viewport-fill: rgba(59, 130, 246, 0.2); + --minimap-viewport-stroke: #3b82f6; +} + +[data-theme="light"] { + --minimap-bg: #f6f8fa; + --minimap-header: #ffffff; + --minimap-footer: #ffffff; + --minimap-border: #d0d7de; + --minimap-viewport-fill: rgba(59, 130, 246, 0.15); + --minimap-viewport-stroke: #2563eb; +} + +/* Container */ +.minimap-container { + position: absolute; + bottom: 20px; + right: 20px; + background: var(--minimap-bg); + border: 1px solid var(--minimap-border); + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.4), + 0 2px 8px rgba(0, 0, 0, 0.2); + overflow: hidden; + z-index: 100; + user-select: none; + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +.minimap-container:hover { + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.5), + 0 4px 12px rgba(0, 0, 0, 0.25); +} + +/* Minimize on hover out - subtle opacity */ +.minimap-container.minimized { + opacity: 0.8; +} + +/* Header */ +.minimap-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + background: var(--minimap-header); + border-bottom: 1px solid var(--minimap-border); +} + +.minimap-title { + font-size: 10px; + font-weight: 600; + color: var(--text-secondary, #8b949e); + text-transform: uppercase; + letter-spacing: 0.8px; + font-family: var(--font-mono, 'SF Mono', Monaco, monospace); +} + +.minimap-hint { + font-size: 9px; + color: var(--text-tertiary, #6e7681); + font-style: italic; +} + +/* SVG Canvas */ +.minimap-svg { + display: block; + cursor: crosshair; + background: var(--minimap-bg); +} + +.minimap-svg:active { + cursor: grabbing; +} + +/* Viewport rectangle styling (via D3, but CSS helps) */ +.viewport-indicator { + pointer-events: all; +} + +.viewport-fill { + transition: fill 0.15s ease; +} + +.viewport-fill:hover { + fill: rgba(59, 130, 246, 0.3); +} + +.viewport-border { + transition: stroke-width 0.15s ease; +} + +.viewport-fill:hover + .viewport-border, +.viewport-border:hover { + stroke-width: 3px; +} + +/* Stats footer */ +.minimap-stats, +.minimap-footer { + display: none; /* Hidden by default */ +} + +.minimap-stat, +.minimap-label { + display: none; +} + +/* Density cells animation */ +.density-layer rect { + transition: opacity 0.2s ease; +} + +/* Grid overlay */ +.grid-overlay line { + pointer-events: none; +} + +/* Light theme adjustments */ +[data-theme="light"] .minimap-container { + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.12), + 0 1px 4px rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .minimap-container:hover { + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.16), + 0 2px 8px rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .minimap-title { + color: var(--text-primary, #24292f); +} + +/* Responsive: hide on small screens */ +@media (max-width: 768px) { + .minimap-container { + display: none; + } +} + +/* Compact variant for smaller screens */ +@media (max-width: 1200px) and (min-width: 769px) { + .minimap-container { + bottom: 12px; + right: 12px; + } + + .minimap-header { + padding: 4px 8px; + } + + .minimap-title { + font-size: 9px; + } +} + +/* Animation for viewport pulsing (subtle attention) */ +@keyframes viewport-pulse { + 0%, 100% { + stroke-opacity: 1; + } + 50% { + stroke-opacity: 0.7; + } +} + +/* Only pulse when first appearing or after navigation */ +.minimap-svg .viewport-border.pulse { + animation: viewport-pulse 1.5s ease-in-out 2; +} + +/* Drag state */ +.minimap-container.dragging .minimap-svg { + cursor: grabbing; +} + +.minimap-container.dragging .viewport-fill, +.minimap-container.dragging .viewport-border { + stroke: #60a5fa; + fill: rgba(96, 165, 250, 0.25); +} + +/* Focus state for accessibility */ +.minimap-svg:focus { + outline: 2px solid var(--minimap-viewport-stroke); + outline-offset: 2px; +} + +.minimap-svg:focus:not(:focus-visible) { + outline: none; +} + +/* Tooltip for interaction hints */ +.minimap-container[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + background: var(--bg-elevated, #1c2128); + color: var(--text-primary, #e6edf3); + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + margin-bottom: 8px; +} + +.minimap-container:hover[data-tooltip]::after { + opacity: 1; +} diff --git a/frontend/src/components/visualizations/MiniMap.tsx b/frontend/src/components/visualizations/MiniMap.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f2a5f462b128004ccd1f738b8364dd1ca15e85b8 --- /dev/null +++ b/frontend/src/components/visualizations/MiniMap.tsx @@ -0,0 +1,317 @@ +import React, { useRef, useEffect, useMemo, useCallback } from 'react'; +import * as d3 from 'd3'; +import { ModelPoint } from '../../types'; + +interface MiniMapProps { + width: number; + height: number; + data: ModelPoint[]; + colorBy: string; + // Main plot dimensions + mainWidth: number; + mainHeight: number; + mainMargin: { top: number; right: number; bottom: number; left: number }; + // Current transform from main plot + transform: d3.ZoomTransform; + // Callback to update main plot transform + onViewportChange?: (transform: d3.ZoomTransform) => void; + // Base scales from main plot + xScaleBase: d3.ScaleLinear; + yScaleBase: d3.ScaleLinear; +} + +export default function MiniMap({ + width, + height, + data, + colorBy, + mainWidth, + mainHeight, + mainMargin, + transform, + onViewportChange, + xScaleBase, + yScaleBase, +}: MiniMapProps) { + const svgRef = useRef(null); + const isDragging = useRef(false); + + // Sample data for mini-map (show fewer points for performance) + const sampledData = useMemo(() => { + const maxPoints = 2000; + if (data.length <= maxPoints) return data; + + const step = Math.ceil(data.length / maxPoints); + return data.filter((_, i) => i % step === 0); + }, [data]); + + // Mini-map scales (fit entire data in mini-map viewport) + const { miniXScale, miniYScale, colorScale } = useMemo(() => { + if (data.length === 0) { + return { + miniXScale: d3.scaleLinear(), + miniYScale: d3.scaleLinear(), + colorScale: () => '#666', + }; + } + + const padding = 8; + const xExtent = d3.extent(data, (d) => d.x) as [number, number]; + const yExtent = d3.extent(data, (d) => d.y) as [number, number]; + + const miniXScale = d3 + .scaleLinear() + .domain(xExtent) + .range([padding, width - padding]); + + const miniYScale = d3 + .scaleLinear() + .domain(yExtent) + .range([height - padding, padding]); + + // Color scale + let colorScale: (d: ModelPoint) => string; + + if (colorBy === 'library_name' || colorBy === 'pipeline_tag') { + const categories = Array.from(new Set(data.map((d) => + colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown') + ))); + const ordinalScale = d3.scaleOrdinal(d3.schemeTableau10).domain(categories); + colorScale = (d: ModelPoint) => { + const value = colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown'); + return ordinalScale(value); + }; + } else { + const values = data.map((d) => colorBy === 'downloads' ? d.downloads : d.likes); + const extent = d3.extent(values) as [number, number]; + const logExtent: [number, number] = [Math.log10(extent[0] + 1), Math.log10(extent[1] + 1)]; + const sequentialScale = d3.scaleSequential(d3.interpolateTurbo).domain(logExtent); + + colorScale = (d: ModelPoint) => { + const val = colorBy === 'downloads' ? d.downloads : d.likes; + return sequentialScale(Math.log10(val + 1)); + }; + } + + return { miniXScale, miniYScale, colorScale }; + }, [data, width, height, colorBy]); + + // Calculate viewport rectangle in mini-map coordinates + const viewportRect = useMemo(() => { + if (!xScaleBase.domain || !yScaleBase.domain) return null; + + const mainPlotWidth = mainWidth - mainMargin.left - mainMargin.right; + const mainPlotHeight = mainHeight - mainMargin.top - mainMargin.bottom; + + // Get the visible domain in data coordinates + const visibleXDomain = transform.rescaleX(xScaleBase).domain(); + const visibleYDomain = transform.rescaleY(yScaleBase).domain(); + + // Convert to mini-map coordinates + const x = miniXScale(visibleXDomain[0]); + const y = miniYScale(visibleYDomain[1]); // Note: y is inverted + const rectWidth = miniXScale(visibleXDomain[1]) - miniXScale(visibleXDomain[0]); + const rectHeight = miniYScale(visibleYDomain[0]) - miniYScale(visibleYDomain[1]); + + return { + x: Math.max(0, x), + y: Math.max(0, y), + width: Math.min(width, rectWidth), + height: Math.min(height, rectHeight), + }; + }, [transform, xScaleBase, yScaleBase, miniXScale, miniYScale, mainWidth, mainHeight, mainMargin, width, height]); + + // Handle click on mini-map to pan + const handleClick = useCallback((event: React.MouseEvent) => { + if (!onViewportChange || !svgRef.current) return; + + const rect = svgRef.current.getBoundingClientRect(); + const clickX = event.clientX - rect.left; + const clickY = event.clientY - rect.top; + + // Convert click position to data coordinates + const dataX = miniXScale.invert(clickX); + const dataY = miniYScale.invert(clickY); + + // Calculate new transform to center on clicked point + const mainPlotWidth = mainWidth - mainMargin.left - mainMargin.right; + const mainPlotHeight = mainHeight - mainMargin.top - mainMargin.bottom; + + // Get center of current viewport in data coordinates + const newCenterX = xScaleBase(dataX); + const newCenterY = yScaleBase(dataY); + + // Calculate translation to center on this point + const newX = mainPlotWidth / 2 - transform.k * newCenterX; + const newY = mainPlotHeight / 2 - transform.k * newCenterY; + + const newTransform = d3.zoomIdentity + .translate(newX, newY) + .scale(transform.k); + + onViewportChange(newTransform); + }, [onViewportChange, miniXScale, miniYScale, xScaleBase, yScaleBase, mainWidth, mainHeight, mainMargin, transform.k]); + + // Handle drag on viewport + const handleMouseDown = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + isDragging.current = true; + document.body.style.cursor = 'grabbing'; + }, []); + + const handleMouseMove = useCallback((event: MouseEvent) => { + if (!isDragging.current || !onViewportChange || !svgRef.current) return; + + const rect = svgRef.current.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + // Convert mouse position to data coordinates + const dataX = miniXScale.invert(mouseX); + const dataY = miniYScale.invert(mouseY); + + // Calculate new transform + const mainPlotWidth = mainWidth - mainMargin.left - mainMargin.right; + const mainPlotHeight = mainHeight - mainMargin.top - mainMargin.bottom; + + const newCenterX = xScaleBase(dataX); + const newCenterY = yScaleBase(dataY); + + const newX = mainPlotWidth / 2 - transform.k * newCenterX; + const newY = mainPlotHeight / 2 - transform.k * newCenterY; + + const newTransform = d3.zoomIdentity + .translate(newX, newY) + .scale(transform.k); + + onViewportChange(newTransform); + }, [onViewportChange, miniXScale, miniYScale, xScaleBase, yScaleBase, mainWidth, mainHeight, mainMargin, transform.k]); + + const handleMouseUp = useCallback(() => { + isDragging.current = false; + document.body.style.cursor = ''; + }, []); + + // Add global mouse event listeners for dragging + useEffect(() => { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); + + // Render mini-map + useEffect(() => { + if (!svgRef.current || sampledData.length === 0) return; + + const svg = d3.select(svgRef.current); + + // Only update points layer, preserve viewport rect + let pointsGroup = svg.select('.minimap-points'); + if (pointsGroup.empty()) { + svg.selectAll('*').remove(); + + // Background + svg.append('rect') + .attr('class', 'minimap-bg') + .attr('width', width) + .attr('height', height) + .attr('fill', 'var(--bg-secondary, #1a1a1a)') + .attr('rx', 4); + + // Points group + pointsGroup = svg.append('g').attr('class', 'minimap-points'); + } + + // Draw points + pointsGroup + .selectAll('circle') + .data(sampledData, (d) => d.model_id) + .join( + (enter) => enter + .append('circle') + .attr('cx', (d) => miniXScale(d.x)) + .attr('cy', (d) => miniYScale(d.y)) + .attr('r', 1.5) + .attr('fill', (d) => colorScale(d)) + .attr('opacity', 0.6), + (update) => update + .attr('cx', (d) => miniXScale(d.x)) + .attr('cy', (d) => miniYScale(d.y)) + .attr('fill', (d) => colorScale(d)), + (exit) => exit.remove() + ); + + }, [sampledData, width, height, miniXScale, miniYScale, colorScale]); + + // Update viewport rectangle separately for performance + useEffect(() => { + if (!svgRef.current || !viewportRect) return; + + const svg = d3.select(svgRef.current); + + // Remove old viewport + svg.selectAll('.viewport-rect, .viewport-border').remove(); + + // Viewport fill + svg.append('rect') + .attr('class', 'viewport-rect') + .attr('x', viewportRect.x) + .attr('y', viewportRect.y) + .attr('width', Math.max(viewportRect.width, 10)) + .attr('height', Math.max(viewportRect.height, 10)) + .attr('fill', 'rgba(74, 144, 226, 0.15)') + .attr('stroke', 'rgba(74, 144, 226, 0.8)') + .attr('stroke-width', 2) + .attr('rx', 2) + .style('cursor', 'grab') + .style('pointer-events', 'all'); + + // Corner handles for visual feedback + const handleSize = 6; + const corners = [ + { x: viewportRect.x, y: viewportRect.y }, + { x: viewportRect.x + viewportRect.width, y: viewportRect.y }, + { x: viewportRect.x, y: viewportRect.y + viewportRect.height }, + { x: viewportRect.x + viewportRect.width, y: viewportRect.y + viewportRect.height }, + ]; + + svg.selectAll('.viewport-handle') + .data(corners) + .join('rect') + .attr('class', 'viewport-handle') + .attr('x', (d) => d.x - handleSize / 2) + .attr('y', (d) => d.y - handleSize / 2) + .attr('width', handleSize) + .attr('height', handleSize) + .attr('fill', 'rgba(74, 144, 226, 1)') + .attr('rx', 1); + + }, [viewportRect]); + + if (data.length === 0) return null; + + return ( +
+
+ Overview Map + Click to navigate +
+ +
+ Zoom: {transform.k.toFixed(1)}x +
+
+ ); +} + diff --git a/frontend/src/components/visualizations/MiniMap3D.tsx b/frontend/src/components/visualizations/MiniMap3D.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fd877c28b3c64d1dfcdce8a21e8eaa02d550fa38 --- /dev/null +++ b/frontend/src/components/visualizations/MiniMap3D.tsx @@ -0,0 +1,342 @@ +import React, { useMemo } from 'react'; +import { Canvas, useThree, useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { ModelPoint } from '../../types'; +import { getCategoricalColorMap, getContinuousColorScale, getDepthColorScale } from '../../utils/rendering/colors'; + +interface MiniMap3DProps { + width?: number; + height?: number; + data: ModelPoint[]; + colorBy: string; + cameraPosition: [number, number, number]; + cameraTarget: [number, number, number]; + onNavigate?: (position: [number, number, number], target: [number, number, number]) => void; +} + +// Mini-map point cloud component +function MiniMapPoints({ + data, + colorBy +}: { + data: ModelPoint[]; + colorBy: string; +}) { + const { positions, colors } = useMemo(() => { + // Sample for performance (max 1000 points for mini-map) + const MAX_POINTS = 1000; + const step = Math.ceil(data.length / MAX_POINTS); + const sampled: ModelPoint[] = []; + + for (let i = 0; i < data.length && sampled.length < MAX_POINTS; i += step) { + sampled.push(data[i]); + } + + const count = sampled.length; + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + + // Calculate color scale + let colorScale: any; + + if (colorBy === 'library_name' || colorBy === 'pipeline_tag') { + const categories = Array.from(new Set(sampled.map((d) => + colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown') + ))); + const colorSchemeType = colorBy === 'library_name' ? 'library' : 'pipeline'; + colorScale = getCategoricalColorMap(categories, colorSchemeType); + } else if (colorBy === 'downloads' || colorBy === 'likes') { + const values = sampled.map((d) => colorBy === 'downloads' ? d.downloads : d.likes); + if (values.length > 0) { + const min = Math.min(...values); + const max = Math.max(...values); + colorScale = getContinuousColorScale(min, max, 'viridis', true); + } + } else if (colorBy === 'family_depth') { + const depths = sampled.map((d) => d.family_depth ?? 0); + if (depths.length > 0) { + const maxDepth = Math.max(...depths, 1); + colorScale = getDepthColorScale(maxDepth, true); + } + } + + sampled.forEach((model, idx) => { + positions[idx * 3] = model.x; + positions[idx * 3 + 1] = model.y; + positions[idx * 3 + 2] = model.z; + + let colorHex = '#60a5fa'; + + if (colorBy === 'library_name' || colorBy === 'pipeline_tag') { + const value = colorBy === 'library_name' + ? (model.library_name || 'unknown') + : (model.pipeline_tag || 'unknown'); + colorHex = colorScale instanceof Map ? colorScale.get(value) || '#60a5fa' : '#60a5fa'; + } else if (colorBy === 'downloads' || colorBy === 'likes') { + const val = colorBy === 'downloads' ? model.downloads : model.likes; + colorHex = typeof colorScale === 'function' ? colorScale(val) : '#60a5fa'; + } else if (colorBy === 'family_depth') { + if (typeof colorScale === 'function') { + colorHex = colorScale(model.family_depth ?? 0); + } + } + + const color = new THREE.Color(colorHex); + colors[idx * 3] = color.r; + colors[idx * 3 + 1] = color.g; + colors[idx * 3 + 2] = color.b; + }); + + return { positions, colors }; + }, [data, colorBy]); + + return ( + + + + + + + + ); +} + +// Camera indicator showing viewing direction +function CameraIndicator({ + position, + target +}: { + position: [number, number, number]; + target: [number, number, number]; +}) { + // Calculate look direction + const lookDir = useMemo(() => { + const dir = new THREE.Vector3( + target[0] - position[0], + target[1] - position[1], + target[2] - position[2] + ).normalize(); + return dir; + }, [position, target]); + + // Create frustum vertices + const frustumGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry(); + + const apex = new THREE.Vector3(...position); + const forward = lookDir.clone().multiplyScalar(3); + const right = new THREE.Vector3().crossVectors(lookDir, new THREE.Vector3(0, 1, 0)).normalize(); + const up = new THREE.Vector3().crossVectors(right, lookDir).normalize(); + + const baseCenter = apex.clone().add(forward); + const halfWidth = 1.5; + const halfHeight = 1; + + const corners = [ + baseCenter.clone().add(right.clone().multiplyScalar(halfWidth)).add(up.clone().multiplyScalar(halfHeight)), + baseCenter.clone().add(right.clone().multiplyScalar(-halfWidth)).add(up.clone().multiplyScalar(halfHeight)), + baseCenter.clone().add(right.clone().multiplyScalar(-halfWidth)).add(up.clone().multiplyScalar(-halfHeight)), + baseCenter.clone().add(right.clone().multiplyScalar(halfWidth)).add(up.clone().multiplyScalar(-halfHeight)), + ]; + + const vertices = new Float32Array([ + apex.x, apex.y, apex.z, corners[0].x, corners[0].y, corners[0].z, + apex.x, apex.y, apex.z, corners[1].x, corners[1].y, corners[1].z, + apex.x, apex.y, apex.z, corners[2].x, corners[2].y, corners[2].z, + apex.x, apex.y, apex.z, corners[3].x, corners[3].y, corners[3].z, + corners[0].x, corners[0].y, corners[0].z, corners[1].x, corners[1].y, corners[1].z, + corners[1].x, corners[1].y, corners[1].z, corners[2].x, corners[2].y, corners[2].z, + corners[2].x, corners[2].y, corners[2].z, corners[3].x, corners[3].y, corners[3].z, + corners[3].x, corners[3].y, corners[3].z, corners[0].x, corners[0].y, corners[0].z, + ]); + + geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); + return geometry; + }, [position, lookDir]); + + return ( + + {/* Camera position sphere */} + + + + + + {/* Target indicator */} + + + + + + {/* Line from camera to target */} + + + + + + + + {/* Viewing frustum */} + + + + + ); +} + +// Axis helper for orientation +function AxisHelper() { + return ( + + {/* X axis - red */} + + + + + + + + {/* Y axis - green */} + + + + + + + + {/* Z axis - blue */} + + + + + + + + ); +} + +// Mini-map camera synced with main view camera +function SyncedMiniMapCamera({ + mainCameraPosition, + mainCameraTarget +}: { + mainCameraPosition: [number, number, number]; + mainCameraTarget: [number, number, number]; +}) { + const { camera } = useThree(); + + useFrame(() => { + // Calculate direction from main camera + const dir = new THREE.Vector3( + mainCameraPosition[0] - mainCameraTarget[0], + mainCameraPosition[1] - mainCameraTarget[1], + mainCameraPosition[2] - mainCameraTarget[2] + ); + + // Scale the distance for the mini-map (fixed overview distance) + const distance = 30; + dir.normalize().multiplyScalar(distance); + + // Position mini-map camera in same direction but at fixed distance + camera.position.set( + mainCameraTarget[0] + dir.x, + mainCameraTarget[1] + dir.y, + mainCameraTarget[2] + dir.z + ); + camera.lookAt(mainCameraTarget[0], mainCameraTarget[1], mainCameraTarget[2]); + camera.updateProjectionMatrix(); + }); + + return null; +} + +export default function MiniMap3D({ + width = 180, + height = 140, + data, + colorBy, + cameraPosition, + cameraTarget, +}: MiniMap3DProps) { + + if (data.length === 0) return null; + + // Calculate canvas height (subtract header height only) + const headerHeight = 24; + const canvasHeight = height - headerHeight; + + return ( +
+
+ VIEWPORT +
+ + + + {/* Ambient light */} + + + {/* Point cloud */} + + + {/* Camera indicator showing user's current view */} + + + {/* Axis helper for orientation */} + + + {/* Mini-map camera synced with main view direction */} + + +
+ ); +} diff --git a/frontend/src/components/visualizations/NetworkGraph.tsx b/frontend/src/components/visualizations/NetworkGraph.tsx index 1ca290ec59e798beb5b5b7428fefded41e70d73d..a575d0c7a4783090f0be0890bb160f5bbd6c69be 100644 --- a/frontend/src/components/visualizations/NetworkGraph.tsx +++ b/frontend/src/components/visualizations/NetworkGraph.tsx @@ -193,7 +193,6 @@ export default function NetworkGraph({ setLinks(e.data.result.links); setIsCalculating(false); } else if (e.data.type === 'error') { - console.error('Worker error:', e.data.error); setIsCalculating(false); } }; diff --git a/frontend/src/components/visualizations/ScatterPlot.css b/frontend/src/components/visualizations/ScatterPlot.css index b57e4eb2697c1a738502043bdc97ee264c4727fa..5d2b3e9f6d00fb8a760c543f8ecf115da70c428f 100644 --- a/frontend/src/components/visualizations/ScatterPlot.css +++ b/frontend/src/components/visualizations/ScatterPlot.css @@ -2,11 +2,22 @@ .scatter-plot-container { position: relative; - font-family: system-ui, -apple-system, sans-serif; + font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + overflow: visible; } .scatter-plot-svg { display: block; + background: var(--bg-tertiary, #1a1a1a); +} + +/* Dark theme support */ +[data-theme="dark"] .scatter-plot-svg { + background: #0a0a0a; +} + +[data-theme="light"] .scatter-plot-svg, +:root:not([data-theme="dark"]) .scatter-plot-svg { background: #fafafa; } diff --git a/frontend/src/components/visualizations/ScatterPlot.tsx b/frontend/src/components/visualizations/ScatterPlot.tsx index 80bf81e97b68a16a67f431baaefb3a2bd55e3870..8b189ad3d67880920e6296885a48f5107bcb01eb 100644 --- a/frontend/src/components/visualizations/ScatterPlot.tsx +++ b/frontend/src/components/visualizations/ScatterPlot.tsx @@ -1,7 +1,9 @@ import React, { useMemo, useRef, useEffect, useState, useCallback } from 'react'; import * as d3 from 'd3'; import { ModelPoint } from '../../types'; +import MiniMap from './MiniMap'; import './ScatterPlot.css'; +import './MiniMap.css'; interface ScatterPlotProps { width: number; @@ -36,8 +38,8 @@ export default function ScatterPlot({ // Performance-optimized sampling with Level of Detail (LOD) const sampledData = useMemo(() => { - // Reduced render limit for better performance (was 25000) - const renderLimit = 10000; + // Increased render limit to support full dataset (using Canvas for performance) + const renderLimit = 150000; // LOD: Reduce further when zoomed out const lodFactor = transform.k < 1 ? 0.5 : 1; // Show 50% when zoomed out @@ -156,6 +158,17 @@ export default function ScatterPlot({ } }, []); + // Handler for mini-map viewport changes + const handleMiniMapViewportChange = useCallback((newTransform: d3.ZoomTransform) => { + if (svgRef.current && zoomRef.current) { + d3.select(svgRef.current) + .transition() + .duration(300) + .ease(d3.easeCubicOut) + .call(zoomRef.current.transform as any, newTransform); + } + }, []); + // Debounced tooltip update const showTooltip = useCallback((d: ModelPoint, x: number, y: number) => { if (!gRef.current) return; @@ -177,7 +190,7 @@ export default function ScatterPlot({ { text: d.model_id.length > 35 ? d.model_id.substring(0, 35) + '...' : d.model_id, bold: true }, { text: `Library: ${d.library_name || 'N/A'}` }, { text: `Pipeline: ${d.pipeline_tag || 'N/A'}` }, - { text: `↓ ${d.downloads.toLocaleString()} | ♥ ${d.likes.toLocaleString()}` }, + { text: `Downloads: ${d.downloads.toLocaleString()} | Likes: ${d.likes.toLocaleString()}` }, { text: 'Click for details', small: true } ]; @@ -553,6 +566,21 @@ export default function ScatterPlot({ Showing {sampledData.length.toLocaleString()} of {data.length.toLocaleString()} points
)} + + {/* Mini-map / Overview Map */} +
); } \ No newline at end of file diff --git a/frontend/src/components/visualizations/ScatterPlot3D.css b/frontend/src/components/visualizations/ScatterPlot3D.css new file mode 100644 index 0000000000000000000000000000000000000000..fcb1679644ef77c2ec069b4358c39100efab020d --- /dev/null +++ b/frontend/src/components/visualizations/ScatterPlot3D.css @@ -0,0 +1,24 @@ +.scatter-3d-container { + width: 100%; + height: 100%; + position: relative; + overflow: visible; +} + +.scatter-3d-empty { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.scatter-3d-empty.dark { + color: #888; +} + +.scatter-3d-empty.light { + color: #666; +} + diff --git a/frontend/src/components/visualizations/ScatterPlot3D.tsx b/frontend/src/components/visualizations/ScatterPlot3D.tsx index fb75dac710f81b811938d283686e07e4a80c7857..65f5bd4accdeae0dcbc4dd566051be1afcacbb69 100644 --- a/frontend/src/components/visualizations/ScatterPlot3D.tsx +++ b/frontend/src/components/visualizations/ScatterPlot3D.tsx @@ -1,272 +1,256 @@ -import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; -import { Canvas, useFrame, useThree } from '@react-three/fiber'; -import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; +import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react'; +import { Canvas, useThree, useFrame } from '@react-three/fiber'; +import { OrbitControls } from '@react-three/drei'; import * as THREE from 'three'; import { ModelPoint } from '../../types'; -import { createSpatialIndex } from '../../utils/rendering/spatialIndex'; -import { adaptiveSampleByDistance } from '../../utils/rendering/frustumCulling'; - -// WebGL context loss recovery -const handleWebGLContextLoss = (event: Event) => { - event.preventDefault(); - // Context will be restored automatically by the browser -}; - -const handleWebGLContextRestored = () => { - // Context restored - component will re-render automatically - console.info('WebGL context restored'); -}; +import { getCategoricalColorMap, getContinuousColorScale, getDepthColorScale } from '../../utils/rendering/colors'; +import MiniMap3D from './MiniMap3D'; +import './ScatterPlot3D.css'; +import './MiniMap.css'; interface ScatterPlot3DProps { data: ModelPoint[]; colorBy: string; sizeBy: string; + colorScheme?: string; onPointClick?: (model: ModelPoint) => void; hoveredModel?: ModelPoint | null; onHover?: (model: ModelPoint | null, position?: { x: number; y: number }) => void; } -function getModelColor(model: ModelPoint, colorBy: string, colorScale: any): string { - if (colorBy === 'library_name' || colorBy === 'pipeline_tag') { - const value = colorBy === 'library_name' - ? (model.library_name || 'unknown') - : (model.pipeline_tag || 'unknown'); - return colorScale.get(value) || '#999999'; - } else { - const val = colorBy === 'downloads' ? model.downloads : model.likes; - const logVal = Math.log10(val + 1); - return colorScale(logVal); - } -} - -function getPointSize(model: ModelPoint, sizeBy: string): number { - if (sizeBy === 'none') return 0.02; - const val = sizeBy === 'downloads' ? model.downloads : model.likes; - const logVal = Math.log10(val + 1); - return 0.01 + (logVal / 7) * 0.04; -} - -function Points({ +function ColoredPoints({ data, colorBy, sizeBy, - onPointClick, - onHover -}: ScatterPlot3DProps) { - const meshRef = useRef(null); - const [hovered, setHovered] = useState(null); - const { camera, size } = useThree(); - const [visiblePoints, setVisiblePoints] = useState(data); - const frameCount = useRef(0); + colorScheme = 'viridis', + onPointClick, + isDarkMode = true +}: ScatterPlot3DProps & { isDarkMode?: boolean }) { + const pointsRef = useRef(null); + const modelLookupRef = useRef([]); + const { raycaster, camera, gl } = useThree(); + + // Sample and prepare data + const geometryData = useMemo(() => { + // Increased limit to support full dataset (150k models) + const MAX_POINTS = 150000; + const step = data.length > MAX_POINTS ? Math.ceil(data.length / MAX_POINTS) : 1; + const sampled: ModelPoint[] = []; + + for (let i = 0; i < data.length && sampled.length < MAX_POINTS; i += step) { + sampled.push(data[i]); + } - const colorScale = useMemo(() => { + const count = sampled.length; + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + const sizes = new Float32Array(count); + + // Calculate color scale + let colorScale: any = () => '#4a90e2'; + if (colorBy === 'library_name' || colorBy === 'pipeline_tag') { - const categories = new Set(data.map((d) => + const categories = Array.from(new Set(sampled.map((d) => colorBy === 'library_name' ? (d.library_name || 'unknown') : (d.pipeline_tag || 'unknown') - )); - const colors = [ - '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', - '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' - ]; - const scale = new Map(); - Array.from(categories).forEach((cat, i) => { - scale.set(cat, colors[i % colors.length]); - }); - return scale; - } else { - return (logVal: number) => { - const t = Math.min(Math.max(logVal / 7, 0), 1); - if (t < 0.5) { - const tt = t * 2; - return `rgb(${Math.floor(tt * 255)}, ${Math.floor(tt * 255)}, ${Math.floor((1 - tt) * 255)})`; + ))); + const colorSchemeType = colorBy === 'library_name' ? 'library' : 'pipeline'; + colorScale = getCategoricalColorMap(categories, colorSchemeType); + } else if (colorBy === 'downloads' || colorBy === 'likes') { + const values = sampled.map((d) => colorBy === 'downloads' ? d.downloads : d.likes); + if (values.length > 0) { + const min = Math.min(...values); + const max = Math.max(...values); + colorScale = getContinuousColorScale(min, max, colorScheme as any, true); + } + } else if (colorBy === 'family_depth') { + const depths = sampled.map((d) => d.family_depth ?? 0); + if (depths.length > 0) { + const maxDepth = Math.max(...depths, 1); + const uniqueDepths = new Set(depths); + + if (uniqueDepths.size <= 2 && maxDepth === 0) { + const categories = Array.from(new Set(sampled.map((d) => d.library_name || 'unknown'))); + colorScale = getCategoricalColorMap(categories, 'library'); } else { - const tt = (t - 0.5) * 2; - return `rgb(${Math.floor(255)}, ${Math.floor((1 - tt) * 255)}, 0)`; + colorScale = getDepthColorScale(maxDepth, isDarkMode); } - }; + } } - }, [data, colorBy]); - - const { positions, colors, sizes, models } = useMemo(() => { - const positions: number[] = []; - const colors: number[] = []; - const sizes: number[] = []; - const models: ModelPoint[] = []; - - visiblePoints.forEach((model) => { - positions.push(model.x, model.y, model.z); + + // Fill arrays + sampled.forEach((model, idx) => { + positions[idx * 3] = model.x; + positions[idx * 3 + 1] = model.y; + positions[idx * 3 + 2] = model.z; - const color = getModelColor(model, colorBy, colorScale); - const threeColor = new THREE.Color(color); - colors.push(threeColor.r, threeColor.g, threeColor.b); + let colorHex: string; + if (colorBy === 'library_name' || colorBy === 'pipeline_tag') { + const value = colorBy === 'library_name' + ? (model.library_name || 'unknown') + : (model.pipeline_tag || 'unknown'); + colorHex = colorScale instanceof Map ? colorScale.get(value) || '#4a90e2' : '#4a90e2'; + } else if (colorBy === 'downloads' || colorBy === 'likes') { + const val = colorBy === 'downloads' ? model.downloads : model.likes; + colorHex = typeof colorScale === 'function' ? colorScale(val) : '#4a90e2'; + } else if (colorBy === 'family_depth') { + if (colorScale instanceof Map) { + const value = model.library_name || 'unknown'; + colorHex = colorScale.get(value) || '#4a90e2'; + } else if (typeof colorScale === 'function') { + colorHex = colorScale(model.family_depth ?? 0); + } else { + colorHex = '#4a90e2'; + } + } else { + colorHex = '#4a90e2'; + } - const size = getPointSize(model, sizeBy); - sizes.push(size); + const color = new THREE.Color(colorHex); - models.push(model); - }); - - return { positions, colors, sizes, models }; - }, [visiblePoints, colorBy, sizeBy, colorScale]); - - useEffect(() => { - if (!meshRef.current || positions.length === 0) return; - - const tempObject = new THREE.Object3D(); - const tempColor = new THREE.Color(); - const count = Math.floor(positions.length / 3); - - for (let i = 0; i < count; i++) { - tempObject.position.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); - tempObject.scale.setScalar(sizes[i]); - tempObject.updateMatrix(); - meshRef.current.setMatrixAt(i, tempObject.matrix); + // Preserve original vibrant colors - no washing out + // The colors from our color utility are already optimized for dark mode - tempColor.setRGB(colors[i * 3], colors[i * 3 + 1], colors[i * 3 + 2]); - meshRef.current.setColorAt(i, tempColor); - } - - meshRef.current.count = count; - meshRef.current.instanceMatrix.needsUpdate = true; - if (meshRef.current.instanceColor) { - meshRef.current.instanceColor.needsUpdate = true; - } - }, [positions, colors, sizes]); - - useEffect(() => { - if (!meshRef.current || positions.length === 0) return; - - const tempObject = new THREE.Object3D(); - const count = Math.floor(positions.length / 3); - - for (let i = 0; i < count; i++) { - const scale = i === hovered ? sizes[i] * 2 : sizes[i]; - tempObject.position.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); - tempObject.scale.setScalar(scale); - tempObject.updateMatrix(); - meshRef.current.setMatrixAt(i, tempObject.matrix); - } - - meshRef.current.instanceMatrix.needsUpdate = true; - }, [hovered, positions, sizes]); - - useFrame(() => { - frameCount.current++; - if (frameCount.current % 10 !== 0) return; - - try { - const sampled = adaptiveSampleByDistance( - data, - camera as THREE.Camera, - 1.0, - 50 - ); + colors[idx * 3] = color.r; + colors[idx * 3 + 1] = color.g; + colors[idx * 3 + 2] = color.b; - // Reduced from 100000 to prevent WebGL context loss - const MAX_RENDER_POINTS = 50000; - if (sampled.length > MAX_RENDER_POINTS) { - const step = Math.ceil(sampled.length / MAX_RENDER_POINTS); - const finalSampled: ModelPoint[] = []; - for (let i = 0; i < sampled.length; i += step) { - finalSampled.push(sampled[i]); - } - setVisiblePoints(finalSampled); + // Calculate size + const baseSize = sizeBy === 'none' ? 8 : 6; + if (sizeBy === 'downloads' || sizeBy === 'likes') { + const val = sizeBy === 'downloads' ? model.downloads : model.likes; + const logVal = Math.log10(val + 1); + sizes[idx] = baseSize + (logVal / 7) * 12; } else { - setVisiblePoints(sampled); - } - } catch (error) { - // Silently handle WebGL errors to prevent console spam - if (error instanceof Error && error.message.includes('WebGL')) { - return; + sizes[idx] = baseSize; } - throw error; - } - }); - - const handlePointerMove = useCallback((event: any) => { - event.stopPropagation(); - const instanceId = event.instanceId; + }); - if (instanceId !== undefined && instanceId !== hovered) { - setHovered(instanceId); - - if (onHover && instanceId < models.length) { - const model = models[instanceId]; - const vector = new THREE.Vector3( - positions[instanceId * 3], - positions[instanceId * 3 + 1], - positions[instanceId * 3 + 2] - ); - vector.project(camera as THREE.Camera); - - const x = (vector.x * 0.5 + 0.5) * size.width; - const y = (-vector.y * 0.5 + 0.5) * size.height; - - onHover(model, { x, y }); - } - } - }, [hovered, onHover, models, positions, camera, size]); - - const handlePointerOut = useCallback(() => { - setHovered(null); - if (onHover) { - onHover(null); - } - }, [onHover]); + modelLookupRef.current = sampled; + return { positions, colors, sizes, count }; + }, [data, colorBy, sizeBy, colorScheme, isDarkMode]); + + // Create geometry + const geometry = useMemo(() => { + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(geometryData.positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(geometryData.colors, 3)); + geo.setAttribute('size', new THREE.BufferAttribute(geometryData.sizes, 1)); + return geo; + }, [geometryData]); + + // Create material + const material = useMemo(() => { + return new THREE.PointsMaterial({ + size: 0.15, + vertexColors: true, + sizeAttenuation: true, + transparent: true, + opacity: 0.9, + }); + }, []); - const handleClick = useCallback((event: any) => { - event.stopPropagation(); - const instanceId = event.instanceId; + // Handle click + const handleClick = (event: any) => { + if (!onPointClick || !pointsRef.current) return; - if (onPointClick && instanceId !== undefined && instanceId < models.length) { - onPointClick(models[instanceId]); + // Get mouse position + const rect = gl.domElement.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); + + raycaster.setFromCamera(mouse, camera); + raycaster.params.Points = { threshold: 0.5 }; + + const intersects = raycaster.intersectObject(pointsRef.current); + if (intersects.length > 0 && intersects[0].index !== undefined) { + const idx = intersects[0].index; + if (idx < modelLookupRef.current.length) { + onPointClick(modelLookupRef.current[idx]); + } } - }, [onPointClick, models]); + }; - if (visiblePoints.length === 0) return null; + if (geometryData.count === 0) return null; - // Reduced max instances to prevent WebGL context loss - const maxInstances = Math.min(50000, Math.max(visiblePoints.length, 1000)); - return ( - - - - + frustumCulled={false} + /> ); } -export default function ScatterPlot3D(props: ScatterPlot3DProps) { - const { data } = props; - const canvasRef = useRef(null); +// Camera tracking component for mini-map +function CameraTracker({ + onCameraUpdate +}: { + onCameraUpdate: (position: [number, number, number], target: [number, number, number]) => void +}) { + const { camera, controls } = useThree(); + + useFrame(() => { + if (camera && controls) { + const orbitControls = controls as any; + const position: [number, number, number] = [camera.position.x, camera.position.y, camera.position.z]; + const target: [number, number, number] = orbitControls.target + ? [orbitControls.target.x, orbitControls.target.y, orbitControls.target.z] + : [0, 0, 0]; + onCameraUpdate(position, target); + } + }); + + return null; +} - useMemo(() => { - if (data.length > 0) { - createSpatialIndex(data); +export default function ScatterPlot3D(props: ScatterPlot3DProps) { + const { data, colorBy } = props; + + const [canvasBg, setCanvasBg] = useState(() => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + return isDark ? '#0a0a0a' : '#ffffff'; + }); + const [isDarkMode, setIsDarkMode] = useState(() => { + return document.documentElement.getAttribute('data-theme') === 'dark'; + }); + + // Camera state for mini-map + const [cameraPosition, setCameraPosition] = useState<[number, number, number]>([0, 0, 10]); + const [cameraTarget, setCameraTarget] = useState<[number, number, number]>([0, 0, 0]); + + // Throttle camera updates + const lastUpdateRef = useRef(0); + const handleCameraUpdate = useCallback((position: [number, number, number], target: [number, number, number]) => { + const now = Date.now(); + if (now - lastUpdateRef.current > 100) { // Update every 100ms + setCameraPosition(position); + setCameraTarget(target); + lastUpdateRef.current = now; } - }, [data]); + }, []); - // Add WebGL context loss handlers useEffect(() => { - const canvas = canvasRef.current?.querySelector('canvas'); - if (canvas) { - canvas.addEventListener('webglcontextlost', handleWebGLContextLoss); - canvas.addEventListener('webglcontextrestored', handleWebGLContextRestored); - - return () => { - canvas.removeEventListener('webglcontextlost', handleWebGLContextLoss); - canvas.removeEventListener('webglcontextrestored', handleWebGLContextRestored); - }; - } + const updateBg = () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + setCanvasBg(isDark ? '#0a0a0a' : '#ffffff'); + setIsDarkMode(isDark); + }; + + updateBg(); + const observer = new MutationObserver(updateBg); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + + return () => observer.disconnect(); }, []); + // Simple bounds calculation const bounds = useMemo(() => { if (data.length === 0) { return { center: [0, 0, 0] as [number, number, number], radius: 10 }; @@ -276,78 +260,96 @@ export default function ScatterPlot3D(props: ScatterPlot3DProps) { let minY = Infinity, maxY = -Infinity; let minZ = Infinity, maxZ = -Infinity; - data.forEach((d) => { - minX = Math.min(minX, d.x); - maxX = Math.max(maxX, d.x); - minY = Math.min(minY, d.y); - maxY = Math.max(maxY, d.y); - minZ = Math.min(minZ, d.z); - maxZ = Math.max(maxZ, d.z); - }); + const step = Math.max(1, Math.floor(data.length / 1000)); + for (let i = 0; i < data.length; i += step) { + const d = data[i]; + if (isFinite(d.x) && isFinite(d.y) && isFinite(d.z)) { + minX = Math.min(minX, d.x); + maxX = Math.max(maxX, d.x); + minY = Math.min(minY, d.y); + maxY = Math.max(maxY, d.y); + minZ = Math.min(minZ, d.z); + maxZ = Math.max(maxZ, d.z); + } + } + + if (!isFinite(minX)) { + return { center: [0, 0, 0] as [number, number, number], radius: 10 }; + } const center: [number, number, number] = [ (minX + maxX) / 2, (minY + maxY) / 2, - (minZ + maxZ) / 2, + (minZ + maxZ) / 2 ]; - - const radius = Math.max( - maxX - minX, - maxY - minY, - maxZ - minZ - ) / 2; + const size = Math.max(maxX - minX, maxY - minY, maxZ - minZ); + const radius = Math.max(size / 2, 1); return { center, radius }; }, [data]); + if (data.length === 0) { + return ( +
+ No data to display +
+ ); + } + return ( -
+
{ - // Suppress WebGL context loss errors - gl.domElement.addEventListener('webglcontextlost', (e) => { - e.preventDefault(); + gl.domElement.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); }); + gl.domElement.addEventListener('webglcontextrestored', () => {}); + }} + camera={{ + position: [ + bounds.center[0] + bounds.radius * 0.5, + bounds.center[1] + bounds.radius * 0.5, + bounds.center[2] + bounds.radius * 0.5, + ], + fov: 45, + near: 0.1, + far: bounds.radius * 20 }} > - + - - - - + - + + + + + + {/* Mini-map / Overview Map */} +
); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 032b4f1aa6fbd023f4bcb599365553dd1fa8ddc7..e7c8918373e2bc1d85fc965431a1e987af8f2fdb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,18 +1,30 @@ -body { +html, body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + padding: 0; + height: 100%; + width: 100%; + background: var(--bg-primary, #0d1117); + overflow-x: hidden; +} + +body { + font-family: 'Overpass', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - color: #1a1a1a; - background: #ffffff; + color: var(--text-primary, #1a1a1a); + transition: background-color var(--transition-base, 200ms), color var(--transition-base, 200ms); } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; + font-family: 'Roboto Mono', 'Monaco', 'Menlo', 'Courier New', Consolas, monospace; } * { box-sizing: border-box; } +#root { + min-height: 100vh; + background: var(--bg-primary, #0d1117); +} + diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 6fdec74eb653c9e83d76ae1de1f2e2525f275012..358e2dcc28b6c5bad86ef7e8ddc6d3ae43800536 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -26,13 +26,18 @@ const isWebGLError = (error: any, message?: string): boolean => { const errorStr = error?.toString() || ''; const messageStr = message?.toString() || ''; const combined = `${errorStr} ${messageStr}`.toLowerCase(); + const stack = error?.stack?.toLowerCase() || ''; return ( combined.includes('webgl') || combined.includes('context lost') || combined.includes('webglrenderer') || + combined.includes('three.module.js') || + combined.includes('three.js') || error?.message?.toLowerCase().includes('webgl') || - error?.stack?.toLowerCase().includes('webgl') + stack.includes('webgl') || + stack.includes('three.module.js') || + stack.includes('three.js') ); }; @@ -52,6 +57,18 @@ window.addEventListener('error', (event) => { event.stopPropagation(); return false; } + // Suppress 404 errors for expected API endpoints + if ( + event.message && + (event.message.includes('404') || event.message.includes('Failed to load resource')) && + (event.message.includes('/api/family/') || + (event.message.includes('/api/model/') && event.message.includes('/papers')) || + event.message.includes('/api/family/path/')) + ) { + event.preventDefault(); + event.stopPropagation(); + return false; + } }, true); // Use capture phase to catch early // Handle unhandled promise rejections related to WebSockets and WebGL @@ -69,31 +86,102 @@ window.addEventListener('unhandledrejection', (event) => { event.preventDefault(); event.stopPropagation(); } + // Suppress 404 promise rejections for expected API endpoints + const reasonStr = String(event.reason || event.promise || ''); + if ( + reasonStr.includes('404') && + (reasonStr.includes('/api/family/') || + (reasonStr.includes('/api/model/') && reasonStr.includes('/papers')) || + reasonStr.includes('/api/family/path/')) + ) { + event.preventDefault(); + event.stopPropagation(); + } }, true); // Use capture phase -// Also suppress console errors for WebSocket and WebGL issues +// Suppress console errors and warnings for WebSocket and WebGL issues +// Must override BEFORE any imports that might log const originalConsoleError = console.error; -console.error = (...args: any[]) => { +const originalConsoleWarn = console.warn; +const originalConsoleLog = console.log; + +// Comprehensive error suppression +const shouldSuppress = (args: any[]): boolean => { const message = args.join(' ').toLowerCase(); + const source = args.find(arg => typeof arg === 'string' && arg.includes('.js')); + + // Check for deprecated MouseEvent warnings (from Three.js OrbitControls) + if ( + message.includes('mouseevent.mozpressure') || + message.includes('mouseevent.mozinputsource') || + message.includes('is deprecated') + ) { + return true; + } + + // Check for WebSocket errors if ( message.includes('websocket') || message.includes('websocketclient') || message.includes('initsocket') ) { - // Suppress WebSocket console errors - return; + return true; } + + // Check for WebGL/Three.js errors (including three.module.js and bundle.js) if ( message.includes('webgl') || message.includes('context lost') || - message.includes('webglrenderer') + message.includes('webglrenderer') || + message.includes('three.webglrenderer') || + message.includes('three.webglrenderer: context lost') || + (source && (source.includes('three.module.js') || + source.includes('three.js') || + source.includes('bundle.js'))) ) { - // Suppress WebGL context loss console errors (handled by component) - return; + return true; + } + + // Check for NetworkError (expected during startup) + if (message.includes('networkerror') || message.includes('network error')) { + return true; + } + + // Suppress 404 errors for expected API endpoints (family, papers, path) + if ( + message.includes('404') && + (message.includes('/api/family/') || + (message.includes('/api/model/') && message.includes('/papers')) || + message.includes('/api/family/path/')) + ) { + return true; + } + + return false; +}; + +console.error = (...args: any[]) => { + if (shouldSuppress(args)) { + return; // Suppress } originalConsoleError.apply(console, args); }; +console.warn = (...args: any[]) => { + if (shouldSuppress(args)) { + return; // Suppress + } + originalConsoleWarn.apply(console, args); +}; + +// Also suppress console.log for Three.js WebGL messages +console.log = (...args: any[]) => { + if (shouldSuppress(args)) { + return; // Suppress + } + originalConsoleLog.apply(console, args); +}; + const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); diff --git a/frontend/src/pages/AnalyticsPage.css b/frontend/src/pages/AnalyticsPage.css new file mode 100644 index 0000000000000000000000000000000000000000..e651838c89344a66f8cb25b3f9c4e0a491d5b956 --- /dev/null +++ b/frontend/src/pages/AnalyticsPage.css @@ -0,0 +1,198 @@ +.analytics-page { + padding: 2rem; + max-width: none; + margin: 0; + min-height: calc(100vh - 200px); + box-sizing: border-box; + width: 100%; +} + +.page-header { + margin-bottom: 2rem; +} + +.page-header h1 { + font-size: 2rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + margin: 0; + letter-spacing: -0.01em; +} + +.analytics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(500px, 100%), 1fr)); + gap: 1.5rem; + width: 100%; +} + +.analytics-card { + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; + overflow: hidden; + transition: all var(--transition-base, 0.2s ease); +} + +.analytics-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.analytics-card.expanded { + grid-column: span 1; +} + +.card-expanded { + padding: 1.5rem; + overflow-x: visible; + width: 100%; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.card-header h3 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + margin: 0; +} + +.time-tabs { + display: flex; + gap: 0.5rem; +} + +.tab { + padding: 0.375rem 0.75rem; + background: var(--bg-secondary, #f5f5f5); + border: 1px solid var(--border-medium, #ddd); + border-radius: 0; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-base, 0.2s); + color: var(--text-secondary, #666); + font-family: var(--font-primary, inherit); +} + +.tab:hover { + background: var(--bg-tertiary, #e8e8e8); +} + +.tab.active { + background: var(--accent-blue, #3b82f6); + color: #ffffff; + border-color: var(--accent-blue, #3b82f6); +} + +.analytics-table { + width: 100%; + border-collapse: collapse; + table-layout: auto; + min-width: 100%; + max-width: 100%; +} + +.analytics-table thead { + background: var(--bg-secondary, #f5f5f5); +} + +.analytics-table th { + padding: 0.75rem; + text-align: left; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #666); + border-bottom: 1px solid var(--border-light, #e0e0e0); + position: sticky; + top: 0; + background: var(--bg-secondary, #f5f5f5); + z-index: 1; +} + +.analytics-table th:first-child { + text-align: center; + width: 60px; +} + +.analytics-table th:last-child { + text-align: right; +} + +.analytics-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border-light, #e0e0e0); + font-size: 0.875rem; + color: var(--text-primary, #1a1a1a); + vertical-align: middle; +} + +.analytics-table td:first-child { + width: 60px; + text-align: center; + font-weight: 600; + color: var(--text-secondary, #666); +} + +.analytics-table td:nth-child(2) { + max-width: none; + overflow: visible; + text-overflow: clip; + white-space: normal; + word-break: break-word; + min-width: 200px; +} + +.analytics-table td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.analytics-table tbody tr { + cursor: pointer; + transition: background-color var(--transition-base, 0.2s); +} + +.analytics-table tbody tr:hover { + background: var(--bg-secondary, #f5f5f5); +} + +.analytics-table tbody tr:last-child td { + border-bottom: none; +} + +.placeholder { + text-align: center; + color: var(--text-secondary, #666); + font-style: italic; + padding: 2rem; +} + +.card-placeholder { + padding: 2rem; + text-align: center; + color: var(--text-secondary, #666); + font-style: italic; +} + +@media (max-width: 768px) { + .analytics-page { + padding: 1rem; + } + + .analytics-grid { + grid-template-columns: 1fr; + } + + .card-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } +} diff --git a/frontend/src/pages/AnalyticsPage.tsx b/frontend/src/pages/AnalyticsPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e9eae332e152a14e8e71b2af155be85137abd7a7 --- /dev/null +++ b/frontend/src/pages/AnalyticsPage.tsx @@ -0,0 +1,350 @@ +import React, { useState, useEffect } from 'react'; +import { API_BASE } from '../config/api'; +import LoadingProgress from '../components/ui/LoadingProgress'; +import './AnalyticsPage.css'; + +interface TopModel { + model_id: string; + downloads?: number; + likes?: number; + trending_score?: number; + created_at?: string; +} + +interface Family { + family: string; + count: number; + growth_rate?: number; +} + +export default function AnalyticsPage() { + const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d'>('30d'); + const [loading, setLoading] = useState(true); + const [loadingProgress, setLoadingProgress] = useState(0); + + const [topDownloads, setTopDownloads] = useState([]); + const [topLikes, setTopLikes] = useState([]); + const [trending, setTrending] = useState([]); + const [newest, setNewest] = useState([]); + const [largestFamilies, setLargestFamilies] = useState([]); + const [fastestGrowing, setFastestGrowing] = useState([]); + + useEffect(() => { + const fetchAnalytics = async () => { + setLoading(true); + setLoadingProgress(0); + + try { + // Fetch models data and sort by different criteria + setLoadingProgress(20); + const response = await fetch(`${API_BASE}/api/models?max_points=10000&format=json`); + if (!response.ok) throw new Error('Failed to fetch models'); + + setLoadingProgress(40); + const data = await response.json(); + const models: TopModel[] = Array.isArray(data) ? data : (data.models || []); + + // Sort by downloads + setLoadingProgress(50); + const sortedByDownloads = [...models] + .sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) + .slice(0, 20); + setTopDownloads(sortedByDownloads); + + // Sort by likes + setLoadingProgress(60); + const sortedByLikes = [...models] + .sort((a, b) => (b.likes || 0) - (a.likes || 0)) + .slice(0, 20); + setTopLikes(sortedByLikes); + + // Sort by trending score + setLoadingProgress(70); + const sortedByTrending = [...models] + .filter(m => m.trending_score !== null && m.trending_score !== undefined) + .sort((a, b) => (b.trending_score || 0) - (a.trending_score || 0)) + .slice(0, 20); + setTrending(sortedByTrending); + + // Sort by created_at (newest) + setLoadingProgress(80); + const sortedByNewest = [...models] + .filter(m => m.created_at) + .sort((a, b) => { + const dateA = new Date(a.created_at || 0).getTime(); + const dateB = new Date(b.created_at || 0).getTime(); + return dateB - dateA; + }) + .slice(0, 20); + setNewest(sortedByNewest); + + // Group by family (using parent_model or model_id prefix) + setLoadingProgress(90); + const familyMap = new Map(); + models.forEach(model => { + // Extract family name from model_id (e.g., "meta-llama/Meta-Llama-3" -> "meta-llama") + const family = model.model_id.split('/')[0]; + familyMap.set(family, (familyMap.get(family) || 0) + 1); + }); + + const families: Family[] = Array.from(familyMap.entries()) + .map(([family, count]) => ({ family, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + setLargestFamilies(families); + setFastestGrowing(families); // TODO: Calculate actual growth rate + + setLoadingProgress(100); + setLoading(false); + } catch { + setLoading(false); + } + }; + + fetchAnalytics(); + }, []); + + const renderCardContent = (cardType: string) => { + switch (cardType) { + case 'downloads': + return ( +
+
+

Top Downloads ({timeRange})

+
+ + + +
+
+ + + + + + + + + + {topDownloads.length > 0 ? ( + topDownloads.map((model, idx) => ( + + + + + + )) + ) : ( + + )} + +
RankModelDownloads
{idx + 1}{model.model_id}{model.downloads?.toLocaleString() || 'N/A'}
Loading...
+
+ ); + case 'likes': + return ( +
+

Top Likes

+ + + + + + + + + + {topLikes.length > 0 ? ( + topLikes.map((model, idx) => ( + + + + + + )) + ) : ( + + )} + +
RankModelLikes
{idx + 1}{model.model_id}{model.likes?.toLocaleString() || 'N/A'}
Loading...
+
+ ); + case 'trending': + return ( +
+

Trending Models

+ + + + + + + + + + {trending.length > 0 ? ( + trending.map((model, idx) => ( + + + + + + )) + ) : ( + + )} + +
RankModelTrending Score
{idx + 1}{model.model_id}{model.trending_score?.toFixed(2) || 'N/A'}
Loading...
+
+ ); + case 'newest': + return ( +
+

Newest Models

+ + + + + + + + + + {newest.length > 0 ? ( + newest.map((model, idx) => ( + + + + + + )) + ) : ( + + )} + +
RankModelCreated
{idx + 1}{model.model_id}{model.created_at ? new Date(model.created_at).toLocaleDateString() : 'N/A'}
Loading...
+
+ ); + case 'largest': + return ( +
+

Largest Families

+ + + + + + + + + + {largestFamilies.length > 0 ? ( + largestFamilies.map((family, idx) => ( + + + + + + )) + ) : ( + + )} + +
RankFamilyModel Count
{idx + 1}{family.family}{family.count.toLocaleString()}
Loading...
+
+ ); + case 'fastest': + return ( +
+

Fastest-Growing Families

+ + + + + + + + + + {fastestGrowing.length > 0 ? ( + fastestGrowing.map((family, idx) => ( + + + + + + )) + ) : ( + + )} + +
RankFamilyModel Count
{idx + 1}{family.family}{family.count.toLocaleString()}
Loading...
+
+ ); + default: + return
Content coming soon
; + } + }; + + if (loading) { + return ( + + ); + } + + return ( +
+
+

Analytics

+
+ +
+
+ {renderCardContent('downloads')} +
+ +
+ {renderCardContent('likes')} +
+ +
+ {renderCardContent('trending')} +
+ +
+ {renderCardContent('newest')} +
+ +
+ {renderCardContent('largest')} +
+ +
+ {renderCardContent('fastest')} +
+
+
+ ); +} diff --git a/frontend/src/pages/FamiliesPage.css b/frontend/src/pages/FamiliesPage.css new file mode 100644 index 0000000000000000000000000000000000000000..e3b94de3375e3a7ff4446a312b1b67c91a87550b --- /dev/null +++ b/frontend/src/pages/FamiliesPage.css @@ -0,0 +1,300 @@ +.families-page { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + min-height: calc(100vh - 200px); +} + +.page-header { + margin-bottom: 2rem; +} + +.page-header h1 { + font-size: 2rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + margin: 0 0 0.5rem 0; + letter-spacing: -0.01em; +} + +.page-description { + font-size: 0.9rem; + color: var(--text-secondary, #666); + margin: 0; + line-height: 1.5; +} + +.families-section { + margin-top: 2rem; +} + +.families-section h2 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text-primary, #1a1a1a); +} + +.families-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.family-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; + transition: border-color var(--transition-base, 0.2s ease), + background-color var(--transition-base, 0.2s ease), + box-shadow var(--transition-base, 0.2s ease); + cursor: pointer; + user-select: none; +} + +.family-item:hover:not(.selected) { + border-color: var(--accent-blue, #3b82f6); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.family-item:active { + opacity: 0.9; +} + +.family-info { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.rank { + font-weight: 600; + color: var(--text-secondary, #666); + min-width: 2rem; + font-size: 1.1rem; +} + +.family-details { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.family-name { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.family-count { + font-size: 0.9rem; + color: var(--text-secondary, #666); +} + +.family-count .tree-count { + font-size: 0.8rem; + color: var(--text-tertiary, #999); + font-weight: 400; +} + +.family-stats { + display: flex; + align-items: center; + gap: 1rem; +} + +.stat-badge { + padding: 0.5rem 1rem; + background: var(--bg-secondary, #f5f5f5); + border: 1px solid var(--border-medium, #ddd); + border-radius: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + min-width: 60px; + text-align: center; +} + +.family-item.selected { + border-color: var(--accent-blue, #3b82f6); + background: rgba(59, 130, 246, 0.05); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); +} + +.family-item.selected:hover { + background: rgba(59, 130, 246, 0.08); +} + +.adoption-section { + margin-bottom: 3rem; + padding: 2rem; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.adoption-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.adoption-controls { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.comparison-toggle-btn { + padding: 0.5rem 1rem; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-medium, #ddd); + border-radius: 0; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + cursor: pointer; + transition: all var(--transition-base, 0.2s ease); +} + +.comparison-toggle-btn:hover { + background: var(--bg-secondary, #f5f5f5); + border-color: var(--accent-blue, #3b82f6); + color: var(--accent-blue, #3b82f6); +} + +.family-selection { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg-secondary, #f5f5f5); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; +} + +.selection-label { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + margin: 0 0 0.75rem 0; +} + +.family-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.family-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary, #1a1a1a); +} + +.family-checkbox input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; +} + +.family-checkbox:hover { + color: var(--accent-blue, #3b82f6); +} + +.adoption-header h2 { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + color: var(--text-primary, #1a1a1a); +} + +.close-button { + background: transparent; + border: 1px solid var(--border-medium, #ddd); + border-radius: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 1.5rem; + color: var(--text-secondary, #666); + transition: all var(--transition-base, 0.2s ease); +} + +.close-button:hover { + background: var(--bg-secondary, #f5f5f5); + border-color: var(--accent-blue, #3b82f6); + color: var(--text-primary, #1a1a1a); +} + +.adoption-curve-wrapper { + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.chart-info { + margin-bottom: 0.5rem; +} + +.chart-subtitle { + font-size: 0.9rem; + color: var(--text-secondary, #666); + margin: 0; +} + +.adoption-empty { + padding: 3rem; + text-align: center; + color: var(--text-secondary, #666); + background: var(--bg-secondary, #f5f5f5); + border: 1px solid var(--border-light, #e0e0e0); + border-radius: 0; +} + +@media (max-width: 768px) { + .families-page { + padding: 1rem; + } + + .family-item { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .family-stats { + width: 100%; + justify-content: flex-end; + } + + .adoption-section { + padding: 1rem; + } + + .adoption-header h2 { + font-size: 1.25rem; + } +} diff --git a/frontend/src/pages/FamiliesPage.tsx b/frontend/src/pages/FamiliesPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..42e4b8b854f00103cdd32785c2695df44fafee8e --- /dev/null +++ b/frontend/src/pages/FamiliesPage.tsx @@ -0,0 +1,397 @@ +import React, { useState, useEffect } from 'react'; +import { API_BASE } from '../config/api'; +import LoadingProgress from '../components/ui/LoadingProgress'; +import AdoptionCurve, { AdoptionDataPoint } from '../components/visualizations/AdoptionCurve'; +import './FamiliesPage.css'; + +interface Family { + family: string; + count: number; + root_model?: string; + family_count?: number; // Number of separate family trees + root_models?: string[]; // List of root models for this org +} + +interface AdoptionModel { + model_id: string; + downloads: number; + created_at: string; +} + +interface FamilyAdoptionData { + family: string; + data: AdoptionDataPoint[]; + color: string; +} + +export default function FamiliesPage() { + const [families, setFamilies] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingProgress, setLoadingProgress] = useState(0); + const [selectedFamily, setSelectedFamily] = useState(null); + const [adoptionData, setAdoptionData] = useState([]); + const [loadingAdoption, setLoadingAdoption] = useState(false); + const [selectedModel, setSelectedModel] = useState(undefined); + + // Comparison mode - enabled by default + const [comparisonMode, setComparisonMode] = useState(true); + const [selectedFamiliesForComparison, setSelectedFamiliesForComparison] = useState>(new Set()); + const [familyAdoptionData, setFamilyAdoptionData] = useState([]); + const [loadingComparison, setLoadingComparison] = useState(false); + + useEffect(() => { + const fetchFamilies = async () => { + setLoading(true); + setLoadingProgress(0); + + try { + setLoadingProgress(20); + + // Fetch models data to count by organization + const response = await fetch(`${API_BASE}/api/models?max_points=10000&format=json`); + if (!response.ok) throw new Error('Failed to fetch models'); + + setLoadingProgress(40); + const data = await response.json(); + const models = Array.isArray(data) ? data : (data.models || []); + + setLoadingProgress(60); + + // Group by organization (model_id prefix) + // Also track models by family_depth to show lineage distribution + const familyMap = new Map }>(); + + models.forEach((model: any) => { + const org = model.model_id?.split('/')[0] || 'unknown'; + const depth = model.family_depth ?? 0; + + if (!familyMap.has(org)) { + familyMap.set(org, { total: 0, byDepth: new Map() }); + } + + const orgData = familyMap.get(org)!; + orgData.total += 1; + orgData.byDepth.set(depth, (orgData.byDepth.get(depth) || 0) + 1); + }); + + setLoadingProgress(80); + + // Convert to array and calculate depth distribution info + const familiesList: Family[] = Array.from(familyMap.entries()) + .map(([family, data]) => { + // Count unique depths to show family tree complexity + const depthCount = data.byDepth.size; + const maxDepth = Math.max(...Array.from(data.byDepth.keys())); + + return { + family, + count: data.total, + family_count: depthCount > 1 ? depthCount : undefined, // Number of depth levels + root_models: maxDepth > 0 ? [`max depth: ${maxDepth}`] : undefined + }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, 50); + + setFamilies(familiesList); + + // Initialize comparison mode with top 5 families (if not already set) + if (familiesList.length >= 5 && selectedFamiliesForComparison.size === 0) { + const top5 = familiesList.slice(0, 5).map(f => f.family); + setSelectedFamiliesForComparison(new Set(top5)); + } + + setLoadingProgress(100); + setLoading(false); + } catch { + setLoading(false); + } + }; + + fetchFamilies(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Fetch adoption data when family is selected + useEffect(() => { + if (!selectedFamily) { + setAdoptionData([]); + return; + } + + const fetchAdoptionData = async () => { + setLoadingAdoption(true); + try { + const response = await fetch(`${API_BASE}/api/family/adoption?family=${encodeURIComponent(selectedFamily)}&limit=200`); + if (!response.ok) throw new Error('Failed to fetch adoption data'); + + const data = await response.json(); + const models: AdoptionModel[] = data.models || []; + + // Transform to AdoptionDataPoint format + const chartData: AdoptionDataPoint[] = models + .filter((m) => m.created_at) + .map((m) => ({ + date: new Date(m.created_at), + downloads: m.downloads, + modelId: m.model_id, + })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + setAdoptionData(chartData); + + // Select the model with highest downloads by default + if (chartData.length > 0) { + const topModel = chartData.reduce((max, current) => + current.downloads > max.downloads ? current : max + ); + setSelectedModel(topModel.modelId); + } + } catch { + setAdoptionData([]); + } finally { + setLoadingAdoption(false); + } + }; + + fetchAdoptionData(); + }, [selectedFamily]); + + // Fetch adoption data for comparison mode + useEffect(() => { + if (!comparisonMode || selectedFamiliesForComparison.size === 0) { + setFamilyAdoptionData([]); + return; + } + + const fetchComparisonData = async () => { + setLoadingComparison(true); + try { + const familyNames = Array.from(selectedFamiliesForComparison); + const FAMILY_COLORS = [ + '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', + '#ec4899', '#06b6d4', '#f97316' + ]; + + const adoptionPromises = familyNames.map(async (familyName, idx): Promise => { + try { + const response = await fetch(`${API_BASE}/api/family/adoption?family=${encodeURIComponent(familyName)}&limit=200`); + if (!response.ok) throw new Error(`Failed to fetch adoption data for ${familyName}`); + + const data = await response.json(); + const models: AdoptionModel[] = data.models || []; + + const chartData: AdoptionDataPoint[] = models + .filter((m) => m.created_at) + .map((m) => ({ + date: new Date(m.created_at), + downloads: m.downloads, + modelId: m.model_id, + })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + return { + family: familyName, + data: chartData, + color: FAMILY_COLORS[idx % FAMILY_COLORS.length], + }; + } catch { + return null; + } + }); + + const results = await Promise.all(adoptionPromises); + const validResults = results.filter((r): r is FamilyAdoptionData => r !== null); + setFamilyAdoptionData(validResults); + } catch { + setFamilyAdoptionData([]); + } finally { + setLoadingComparison(false); + } + }; + + fetchComparisonData(); + }, [comparisonMode, selectedFamiliesForComparison]); + + if (loading) { + return ( + + ); + } + + return ( +
+
+

Model Families

+

+ Explore the largest model families on Hugging Face, organized by organization and model lineage. +

+
+ + {/* Adoption Curve Section - Always visible */} +
+
+

+ Adoption Curve +

+
+ +
+
+ + {comparisonMode ? ( + <> + {/* Family selection checkboxes */} +
+

Select families to compare:

+
+ {families.slice(0, 10).map((family) => ( + + ))} +
+
+ + {loadingComparison ? ( + + ) : familyAdoptionData.length > 0 ? ( +
+
+

+ {families + .filter(f => selectedFamiliesForComparison.has(f.family)) + .reduce((sum, f) => sum + f.count, 0) + .toLocaleString()} models across selected organizations • Cumulative downloads over time +

+
+ +
+ ) : ( +
+

No adoption data available for selected families.

+
+ )} + + ) : ( + <> + {loadingAdoption ? ( + + ) : adoptionData.length > 0 ? ( +
+
+

+ {adoptionData.length} models • Cumulative downloads over time +

+
+ +
+ ) : ( +
+

No adoption data available for this family.

+
+ )} + + )} +
+ +
+

Top Families by Model Count

+
+ {families.map((family, idx) => ( +
{ + if (selectedFamily === family.family) { + setSelectedFamily(null); + setAdoptionData([]); + setSelectedModel(undefined); + } else { + setSelectedFamily(family.family); + } + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (selectedFamily === family.family) { + setSelectedFamily(null); + setAdoptionData([]); + setSelectedModel(undefined); + } else { + setSelectedFamily(family.family); + } + } + }} + > +
+ {idx + 1}. +
+ {family.family} + + {family.count.toLocaleString()} models + +
+
+
+
+ {((family.count / families.reduce((sum, f) => sum + f.count, 0)) * 100).toFixed(1)}% +
+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/stores/filterStore.ts b/frontend/src/stores/filterStore.ts index 176c48edac74c26e79cd0c6a23a05446b664fbba..8fe9d002089b400bee158e78f98d8a62aeaa90c5 100644 --- a/frontend/src/stores/filterStore.ts +++ b/frontend/src/stores/filterStore.ts @@ -6,11 +6,11 @@ import { create } from 'zustand'; export type ColorByOption = 'domain' | 'license' | 'family' | 'library' | 'library_name' | 'pipeline_tag' | 'cluster_id' | 'downloads' | 'likes' | 'family_depth' | 'trending_score' | 'licenses'; export type SizeByOption = 'downloads' | 'likes' | 'none'; -export type ViewMode = 'scatter' | '3d' | 'network' | 'distribution'; +export type ViewMode = '3d' | 'network' | 'distribution'; export type RenderingStyle = 'embeddings' | 'sphere' | 'galaxy' | 'wave' | 'helix' | 'torus'; export type Theme = 'light' | 'dark'; -interface FilterState { +export interface FilterState { // Filters domains: string[]; licenses: string[]; diff --git a/frontend/src/utils/rendering/colors.ts b/frontend/src/utils/rendering/colors.ts index b3074dcda8e1095f22a7c486ae54c685875f5a1c..da2e4994136fbbe199c2ea43b302f5d1f1e10db4 100644 --- a/frontend/src/utils/rendering/colors.ts +++ b/frontend/src/utils/rendering/colors.ts @@ -3,115 +3,182 @@ * Supports categorical and continuous color scales. */ -// Extended color palettes for better variety - Enhanced vibrancy +// Extended color palettes - HIGHLY VIBRANT for dark mode visibility export const CATEGORICAL_COLORS = [ - '#2563eb', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', - '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1', - '#14b8a6', '#a855f7', '#f43f5e', '#0ea5e9', '#22c55e', - '#eab308', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', - '#6b6ecf', '#b5cf6b', '#bd9e39', '#e7969c', '#7b4173', - '#a55194', '#ce6dbd', '#de9ed6', '#636363', '#8ca252', - '#b5a252', '#d6616b', '#e7ba52', '#ad494a', '#843c39', - '#d6616b', '#e7969c', '#e7ba52', '#b5cf6b', '#8ca252', - '#637939', '#bd9e39', '#d6616b', '#e7969c', '#e7ba52', + '#60a5fa', '#fbbf24', '#34d399', '#f87171', '#a78bfa', // Bright versions + '#f472b6', '#22d3ee', '#a3e635', '#fb923c', '#818cf8', + '#2dd4bf', '#c084fc', '#fb7185', '#38bdf8', '#4ade80', + '#facc15', '#3b82f6', '#a855f7', '#ec4899', '#06b6d4', + '#00ff88', '#ff6b6b', '#4ecdc4', '#ffe66d', '#95e1d3', + '#ff9ff3', '#54a0ff', '#5f27cd', '#00d2d3', '#ff9f43', + '#ee5a24', '#0abde3', '#10ac84', '#ff6b81', '#7bed9f', + '#70a1ff', '#5352ed', '#ff4757', '#2ed573', '#ffa502', ]; -// Color schemes for different features - Enhanced vibrancy +// Color schemes for different features - EXTRA VIBRANT for dark mode +// Grouped by semantic meaning: NLP (blues), Vision (greens), Audio (purples), Generative (reds/oranges) export const LIBRARY_COLORS: Record = { - 'transformers': '#2563eb', - 'diffusers': '#f59e0b', - 'sentence-transformers': '#10b981', - 'timm': '#ef4444', - 'speechbrain': '#8b5cf6', - 'fairseq': '#ec4899', - 'espnet': '#06b6d4', - 'asteroid': '#84cc16', - 'keras': '#f97316', - 'sklearn': '#6366f1', - 'unknown': '#9ca3af', + // NLP / Text frameworks - Bright Blues and Cyans + 'transformers': '#60a5fa', // Bright blue - most common + 'sentence-transformers': '#22d3ee', // Bright cyan + 'fairseq': '#38bdf8', // Sky blue + 'spacy': '#2dd4bf', // Teal + + // Vision frameworks - Bright Greens and Limes + 'timm': '#4ade80', // Bright green + 'torchvision': '#a3e635', // Bright lime + 'mmdet': '#bef264', // Yellow-green + + // Diffusion / Generative - Bright Oranges and Reds + 'diffusers': '#fb923c', // Bright orange + 'stable-baselines3': '#fdba74', // Light orange + + // Audio frameworks - Bright Purples and Pinks + 'speechbrain': '#c084fc', // Bright purple + 'espnet': '#e879f9', // Bright fuchsia + 'asteroid': '#f472b6', // Bright pink + + // ML frameworks - Bright Warm colors + 'keras': '#fbbf24', // Bright amber + 'sklearn': '#facc15', // Bright yellow + 'pytorch': '#f87171', // Bright red + + // Other + 'unknown': '#cbd5e1', // Light slate }; export const PIPELINE_COLORS: Record = { - 'text-classification': '#2563eb', - 'token-classification': '#f59e0b', - 'question-answering': '#10b981', - 'summarization': '#ef4444', - 'translation': '#8b5cf6', - 'text-generation': '#ec4899', - 'fill-mask': '#06b6d4', - 'zero-shot-classification': '#84cc16', - 'automatic-speech-recognition': '#f97316', - 'text-to-speech': '#6366f1', - 'image-classification': '#14b8a6', - 'object-detection': '#a855f7', - 'image-segmentation': '#f43f5e', - 'image-to-text': '#0ea5e9', - 'text-to-image': '#22c55e', - 'unknown': '#9ca3af', + // Text tasks - Bright Blues + 'text-classification': '#60a5fa', + 'token-classification': '#93c5fd', + 'question-answering': '#38bdf8', + 'fill-mask': '#22d3ee', + 'text-generation': '#2dd4bf', + 'summarization': '#5eead4', + 'translation': '#99f6e4', + 'zero-shot-classification': '#a5f3fc', + + // Vision tasks - Bright Greens + 'image-classification': '#4ade80', + 'object-detection': '#86efac', + 'image-segmentation': '#bbf7d0', + 'image-to-text': '#bef264', + + // Generative tasks - Bright Oranges/Reds + 'text-to-image': '#fb923c', + 'image-to-image': '#fdba74', + + // Audio tasks - Bright Purples + 'automatic-speech-recognition': '#c084fc', + 'text-to-speech': '#d8b4fe', + 'audio-classification': '#e879f9', + + // Other + 'unknown': '#cbd5e1', }; -// Continuous color scales with optional logarithmic scaling +// Depth-based color scale - Multi-hue gradient for maximum visibility +// Root models are bright cyan, deepest are bright magenta +export function getDepthColorScale(maxDepth: number, isDarkMode: boolean = true): (depth: number) => string { + return (depth: number) => { + // Normalize depth to 0-1 range + const normalized = Math.max(0, Math.min(1, depth / Math.max(maxDepth, 1))); + + if (isDarkMode) { + // Dark mode: Use a vibrant multi-hue gradient (cyan -> green -> yellow -> orange -> pink) + // This provides maximum distinguishability between depth levels + if (normalized < 0.25) { + // Cyan to Green + const t = normalized * 4; + return `rgb(${Math.floor(34 + (74 - 34) * t)}, ${Math.floor(211 + (222 - 211) * t)}, ${Math.floor(238 + (128 - 238) * t)})`; + } else if (normalized < 0.5) { + // Green to Yellow + const t = (normalized - 0.25) * 4; + return `rgb(${Math.floor(74 + (250 - 74) * t)}, ${Math.floor(222 + (204 - 222) * t)}, ${Math.floor(128 + (21 - 128) * t)})`; + } else if (normalized < 0.75) { + // Yellow to Orange + const t = (normalized - 0.5) * 4; + return `rgb(${Math.floor(250 + (251 - 250) * t)}, ${Math.floor(204 + (146 - 204) * t)}, ${Math.floor(21 + (60 - 21) * t)})`; + } else { + // Orange to Pink/Magenta + const t = (normalized - 0.75) * 4; + return `rgb(${Math.floor(251 + (244 - 251) * t)}, ${Math.floor(146 + (114 - 146) * t)}, ${Math.floor(60 + (182 - 60) * t)})`; + } + } else { + // Light mode: Darker, more saturated colors + if (normalized < 0.5) { + const t = normalized * 2; + return `rgb(${Math.floor(30 + (100 - 30) * t)}, ${Math.floor(100 + (50 - 100) * t)}, ${Math.floor(200 + (150 - 200) * t)})`; + } else { + const t = (normalized - 0.5) * 2; + return `rgb(${Math.floor(100 + (150 - 100) * t)}, ${Math.floor(50 + (30 - 50) * t)}, ${Math.floor(150 + (100 - 150) * t)})`; + } + } + }; +} + +// Continuous color scales - EXTRA VIBRANT for dark mode visibility export function getContinuousColorScale( min: number, max: number, scheme: 'viridis' | 'plasma' | 'inferno' | 'magma' | 'coolwarm' = 'viridis', useLogScale: boolean = false ): (value: number) => string { - // Use logarithmic scaling for heavily skewed distributions (like downloads/likes) - // This provides better visual representation of the data distribution const range = max - min || 1; const logMin = useLogScale && min > 0 ? Math.log10(min + 1) : min; const logMax = useLogScale && max > 0 ? Math.log10(max + 1) : max; const logRange = logMax - logMin || 1; - // Viridis-like color scale (blue to yellow) - Enhanced vibrancy + // Viridis - Bright cyan to bright yellow (enhanced for dark mode) const viridis = (t: number) => { - // Apply gamma correction for more vibrant colors - const gamma = 0.7; - const tGamma = Math.pow(t, gamma); - const r = Math.floor(68 + (253 - 68) * tGamma); - const g = Math.floor(1 + (231 - 1) * tGamma); - const b = Math.floor(84 + (37 - 84) * tGamma); - // Increase saturation slightly - return `rgb(${Math.min(255, r)}, ${Math.min(255, g)}, ${Math.min(255, b)})`; + if (t < 0.33) { + const s = t * 3; + return `rgb(${Math.floor(68 + (32 - 68) * s)}, ${Math.floor(170 + (200 - 170) * s)}, ${Math.floor(220 + (170 - 220) * s)})`; + } else if (t < 0.66) { + const s = (t - 0.33) * 3; + return `rgb(${Math.floor(32 + (120 - 32) * s)}, ${Math.floor(200 + (220 - 200) * s)}, ${Math.floor(170 + (90 - 170) * s)})`; + } else { + const s = (t - 0.66) * 3; + return `rgb(${Math.floor(120 + (253 - 120) * s)}, ${Math.floor(220 + (231 - 220) * s)}, ${Math.floor(90 + (37 - 90) * s)})`; + } }; - // Plasma color scale (purple to yellow) - Enhanced vibrancy + // Plasma - Bright purple to bright yellow const plasma = (t: number) => { - const gamma = 0.7; - const tGamma = Math.pow(t, gamma); - const r = Math.floor(13 + (240 - 13) * tGamma); - const g = Math.floor(8 + (249 - 8) * tGamma); - const b = Math.floor(135 + (33 - 135) * tGamma); - return `rgb(${Math.min(255, r)}, ${Math.min(255, g)}, ${Math.min(255, b)})`; + if (t < 0.33) { + const s = t * 3; + return `rgb(${Math.floor(100 + (180 - 100) * s)}, ${Math.floor(50 + (50 - 50) * s)}, ${Math.floor(200 + (220 - 200) * s)})`; + } else if (t < 0.66) { + const s = (t - 0.33) * 3; + return `rgb(${Math.floor(180 + (240 - 180) * s)}, ${Math.floor(50 + (100 - 50) * s)}, ${Math.floor(220 + (150 - 220) * s)})`; + } else { + const s = (t - 0.66) * 3; + return `rgb(${Math.floor(240 + (255 - 240) * s)}, ${Math.floor(100 + (220 - 100) * s)}, ${Math.floor(150 + (50 - 150) * s)})`; + } }; - // Inferno color scale (black to yellow) - Enhanced vibrancy + // Inferno - Dark red to bright yellow const inferno = (t: number) => { - const gamma = 0.6; - const tGamma = Math.pow(t, gamma); - const r = Math.floor(0 + (252 - 0) * tGamma); - const g = Math.floor(0 + (141 - 0) * tGamma); - const b = Math.floor(4 + (89 - 4) * tGamma); - return `rgb(${Math.min(255, r)}, ${Math.min(255, g)}, ${Math.min(255, b)})`; + if (t < 0.33) { + const s = t * 3; + return `rgb(${Math.floor(60 + (150 - 60) * s)}, ${Math.floor(20 + (40 - 20) * s)}, ${Math.floor(80 + (100 - 80) * s)})`; + } else if (t < 0.66) { + const s = (t - 0.33) * 3; + return `rgb(${Math.floor(150 + (230 - 150) * s)}, ${Math.floor(40 + (100 - 40) * s)}, ${Math.floor(100 + (50 - 100) * s)})`; + } else { + const s = (t - 0.66) * 3; + return `rgb(${Math.floor(230 + (255 - 230) * s)}, ${Math.floor(100 + (200 - 100) * s)}, ${Math.floor(50 + (70 - 50) * s)})`; + } }; - // Cool-warm color scale (blue to red) + // Cool-warm - Bright cyan to bright red const coolwarm = (t: number) => { if (t < 0.5) { - // Cool (blue) const s = t * 2; - const r = Math.floor(59 * s); - const g = Math.floor(76 * s); - const b = Math.floor(192 + (255 - 192) * s); - return `rgb(${r}, ${g}, ${b})`; + return `rgb(${Math.floor(80 + (200 - 80) * s)}, ${Math.floor(180 + (200 - 180) * s)}, ${Math.floor(255 + (220 - 255) * s)})`; } else { - // Warm (red) const s = (t - 0.5) * 2; - const r = Math.floor(180 + (255 - 180) * s); - const g = Math.floor(4 + (180 - 4) * s); - const b = Math.floor(38 * (1 - s)); - return `rgb(${r}, ${g}, ${b})`; + return `rgb(${Math.floor(200 + (255 - 200) * s)}, ${Math.floor(200 + (100 - 200) * s)}, ${Math.floor(220 + (100 - 220) * s)})`; } };