Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Beautiful custom timeline visualization for Transformers models using Flask. | |
| """ | |
| import glob | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import time | |
| import webbrowser | |
| from datetime import datetime | |
| from typing import Optional | |
| from flask import Flask, jsonify, render_template, request | |
| import transformers | |
| try: | |
| import yaml # type: ignore | |
| except Exception: # pragma: no cover | |
| yaml = None | |
| class TransformersTimelineParser: | |
| """Parser for extracting model release dates from Transformers documentation.""" | |
| def __init__(self, docs_dir: str): | |
| self.docs_dir = docs_dir | |
| self.models_cache = None | |
| self.tasks_cache = {} | |
| # Add transformers source directory to Python path to import auto mappings | |
| transformers_src = os.path.join(os.path.dirname(docs_dir), "..", "..", "src") | |
| if transformers_src not in sys.path: | |
| sys.path.insert(0, transformers_src) | |
| # Parse modalities dynamically; no fallback to static definitions | |
| parsed_modalities = self._parse_modalities_from_toctree() | |
| if not parsed_modalities: | |
| raise RuntimeError("Failed to parse modalities from docs toctree (_toctree.yml)") | |
| self.modalities = parsed_modalities | |
| def _parse_modalities_from_toctree(self) -> Optional[dict[str, dict[str, object]]]: | |
| """Parse model modalities and slugs from docs/source/en/_toctree.yml. | |
| Returns a dict with the same schema as self.modalities or None on failure. | |
| """ | |
| # Compute toctree path relative to provided docs_dir | |
| toctree_path = os.path.join(self.docs_dir, "..", "_toctree.yml") | |
| if not os.path.isfile(toctree_path): | |
| return None | |
| if yaml is None: | |
| return None | |
| with open(toctree_path, "r", encoding="utf-8") as f: | |
| data = yaml.safe_load(f) | |
| if not isinstance(data, list): | |
| return None | |
| # Locate API -> Models | |
| api_top = None | |
| for entry in data: | |
| if isinstance(entry, dict) and entry.get("title") == "API" and entry.get("sections"): | |
| api_top = entry | |
| break | |
| if api_top is None: | |
| def _dfs_find_api(node): | |
| if isinstance(node, dict) and node.get("title") == "API" and node.get("sections"): | |
| return node | |
| if isinstance(node, dict): | |
| for v in node.values(): | |
| found = _dfs_find_api(v) | |
| if found is not None: | |
| return found | |
| if isinstance(node, list): | |
| for v in node: | |
| found = _dfs_find_api(v) | |
| if found is not None: | |
| return found | |
| return None | |
| api_top = _dfs_find_api(data) | |
| if api_top is None: | |
| return None | |
| models_top = None | |
| for sec in api_top.get("sections", []): | |
| if isinstance(sec, dict) and sec.get("title") == "Models" and sec.get("sections"): | |
| models_top = sec | |
| break | |
| if models_top is None: | |
| def _dfs_find_models(node): | |
| if isinstance(node, dict) and node.get("title") == "Models" and node.get("sections"): | |
| return node | |
| if isinstance(node, dict): | |
| for v in node.values(): | |
| found = _dfs_find_models(v) | |
| if found is not None: | |
| return found | |
| if isinstance(node, list): | |
| for v in node: | |
| found = _dfs_find_models(v) | |
| if found is not None: | |
| return found | |
| return None | |
| models_top = _dfs_find_models(api_top) | |
| if models_top is None: | |
| return None | |
| # Helper to extract slugs from a section like "Text models" | |
| def extract_model_slugs(section_title: str) -> list[str]: | |
| result: list[str] = [] | |
| for sec in models_top.get("sections", []): | |
| if isinstance(sec, dict) and sec.get("title") == section_title: | |
| # Items may be nested under sections -> sections -> list of {local: model_doc/<slug>, title: ...} | |
| nested = sec.get("sections") or [] | |
| for sub in nested: | |
| if not isinstance(sub, dict): | |
| continue | |
| # Direct list: | |
| if "local" in sub: | |
| local = sub.get("local") | |
| if isinstance(local, str) and local.startswith("model_doc/"): | |
| result.append(local.split("/", 1)[1]) | |
| # Or deeper nesting | |
| for leaf in sub.get("sections", []) if isinstance(sub.get("sections"), list) else []: | |
| local = leaf.get("local") | |
| if isinstance(local, str) and local.startswith("model_doc/"): | |
| result.append(local.split("/", 1)[1]) | |
| return result | |
| text_models = extract_model_slugs("Text models") | |
| vision_models = extract_model_slugs("Vision models") | |
| audio_models = extract_model_slugs("Audio models") | |
| video_models = extract_model_slugs("Video models") | |
| multimodal_models = extract_model_slugs("Multimodal models") | |
| rl_models = extract_model_slugs("Reinforcement learning models") | |
| ts_models = extract_model_slugs("Time series models") | |
| graph_models = extract_model_slugs("Graph models") | |
| # Basic validation: require at least some categories to be non-empty | |
| if not any([text_models, vision_models, audio_models, video_models, multimodal_models]): | |
| return None | |
| # Preserve existing names and colors | |
| return { | |
| "text": {"name": "Text Models", "color": "#F59E0B", "models": text_models}, | |
| "vision": {"name": "Vision Models", "color": "#06B6D4", "models": vision_models}, | |
| "audio": {"name": "Audio Models", "color": "#8B5CF6", "models": audio_models}, | |
| "video": {"name": "Video Models", "color": "#EC4899", "models": video_models}, | |
| "multimodal": {"name": "Multimodal Models", "color": "#10B981", "models": multimodal_models}, | |
| "reinforcement": {"name": "Reinforcement Learning", "color": "#EF4444", "models": rl_models}, | |
| "timeseries": {"name": "Time Series Models", "color": "#F97316", "models": ts_models}, | |
| "graph": {"name": "Graph Models", "color": "#6B7280", "models": graph_models}, | |
| } | |
| def get_model_modality(self, model_name: str) -> dict[str, str]: | |
| """Determine the modality category for a given model.""" | |
| for modality_key, modality_info in self.modalities.items(): | |
| if model_name in modality_info["models"]: | |
| return {"key": modality_key, "name": modality_info["name"], "color": modality_info["color"]} | |
| # Default to text if not found (most common) | |
| return {"key": "text", "name": "Text Models", "color": "#F59E0B"} | |
| def parse_release_date_from_file(self, file_path: str) -> Optional[dict[str, str]]: | |
| """Parse the release date line from a model documentation file.""" | |
| try: | |
| with open(file_path, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| # Extract model name from file path (always available) | |
| model_name = os.path.basename(file_path).replace(".md", "") | |
| # Initialize default values | |
| release_date = None | |
| transformers_date = None | |
| # Focus on the end of the sentence - the Transformers addition date is what matters most | |
| pattern = ( | |
| r"\*This model was released on (.+?) and added to Hugging Face Transformers on (\d{4}-\d{2}-\d{2})\.\*" | |
| ) | |
| match = re.search(pattern, content) | |
| if match: | |
| release_date = match.group(1).strip() | |
| transformers_date = match.group(2) | |
| # Validate the Transformers date (this is the critical one for our timeline) | |
| try: | |
| datetime.strptime(transformers_date, "%Y-%m-%d") | |
| except ValueError: | |
| return None | |
| # Handle release_date - could be "None" or an actual date | |
| if release_date.lower() == "none": | |
| release_date = None | |
| else: | |
| # Try to validate as a date, but don't fail if it's not | |
| try: | |
| datetime.strptime(release_date, "%Y-%m-%d") | |
| except ValueError: | |
| # Keep the original value even if it's not a valid date | |
| pass | |
| else: | |
| # No release date pattern found - warn and skip (ignore auto.md intentionally) | |
| base = os.path.basename(file_path) | |
| if base != "auto.md": | |
| print(f"⚠️ Warning: No release/addition dates found in {file_path}; skipping.") | |
| return None | |
| # Get modality information | |
| modality = self.get_model_modality(model_name) | |
| # Extract model description | |
| description = self.extract_model_description(content) | |
| # Get supported tasks/pipelines | |
| tasks = self.get_model_tasks(model_name) | |
| return { | |
| "model_name": model_name, | |
| "file_path": file_path, | |
| "release_date": release_date, | |
| "transformers_date": transformers_date, | |
| "modality": modality["key"], | |
| "modality_name": modality["name"], | |
| "modality_color": modality["color"], | |
| "description": description, | |
| "tasks": tasks, | |
| } | |
| except Exception as e: | |
| print(f"Error processing {file_path}: {e}") | |
| return None | |
| def extract_model_description(self, content: str) -> str: | |
| """Extract the first 1000 characters of model description, excluding HTML/XML tags.""" | |
| try: | |
| # Remove HTML/XML tags | |
| content_no_tags = re.sub(r"<[^>]+>", "", content) | |
| # Find the start of the actual description (after the initial metadata) | |
| # Look for the first substantial paragraph after the initial lines | |
| lines = content_no_tags.split("\n") | |
| description_start = 0 | |
| # Skip initial metadata, imports, and short lines | |
| for i, line in enumerate(lines): | |
| stripped = line.strip() | |
| if ( | |
| len(stripped) > 50 | |
| and not stripped.startswith("#") | |
| and not stripped.startswith("*This model was released") | |
| and not stripped.startswith("<!--") | |
| and not stripped.startswith("from ") | |
| and not stripped.startswith("import ") | |
| and "preview" not in stripped.lower() | |
| and not stripped.startswith(">>>") | |
| ): | |
| description_start = i | |
| break | |
| # Join the description lines and limit to 1000 characters | |
| description_lines = lines[description_start:] | |
| description = "\n".join(description_lines).strip() | |
| if len(description) > 1000: | |
| description = description[:1000] | |
| # Try to cut at a word boundary | |
| last_space = description.rfind(" ") | |
| if last_space > 800: # Only cut at word boundary if it's not too short | |
| description = description[:last_space] | |
| description += "..." | |
| return description | |
| except Exception as e: | |
| print(f"Error extracting description: {e}") | |
| return "No description available." | |
| def load_model_task_mappings(self) -> dict[str, list[str]]: | |
| """Load model-to-task mappings from transformers auto model mappings.""" | |
| if self.tasks_cache: | |
| return self.tasks_cache | |
| try: | |
| # Import the model mappings from transformers | |
| from transformers.models.auto.modeling_auto import ( | |
| MODEL_FOR_AUDIO_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_AUDIO_FRAME_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_AUDIO_XVECTOR_MAPPING_NAMES, | |
| MODEL_FOR_CAUSAL_IMAGE_MODELING_MAPPING_NAMES, | |
| MODEL_FOR_CAUSAL_LM_MAPPING_NAMES, | |
| MODEL_FOR_CTC_MAPPING_NAMES, | |
| MODEL_FOR_DEPTH_ESTIMATION_MAPPING_NAMES, | |
| MODEL_FOR_DOCUMENT_QUESTION_ANSWERING_MAPPING_NAMES, | |
| MODEL_FOR_IMAGE_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_IMAGE_SEGMENTATION_MAPPING_NAMES, | |
| MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES, | |
| MODEL_FOR_IMAGE_TO_IMAGE_MAPPING_NAMES, | |
| MODEL_FOR_INSTANCE_SEGMENTATION_MAPPING_NAMES, | |
| MODEL_FOR_KEYPOINT_DETECTION_MAPPING_NAMES, | |
| MODEL_FOR_KEYPOINT_MATCHING_MAPPING_NAMES, | |
| MODEL_FOR_MASK_GENERATION_MAPPING_NAMES, | |
| MODEL_FOR_MASKED_IMAGE_MODELING_MAPPING_NAMES, | |
| MODEL_FOR_MASKED_LM_MAPPING_NAMES, | |
| MODEL_FOR_MULTIPLE_CHOICE_MAPPING_NAMES, | |
| MODEL_FOR_OBJECT_DETECTION_MAPPING_NAMES, | |
| MODEL_FOR_QUESTION_ANSWERING_MAPPING_NAMES, | |
| MODEL_FOR_SEMANTIC_SEGMENTATION_MAPPING_NAMES, | |
| MODEL_FOR_SEQ_TO_SEQ_CAUSAL_LM_MAPPING_NAMES, | |
| MODEL_FOR_SEQUENCE_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_SPEECH_SEQ_2_SEQ_MAPPING_NAMES, | |
| MODEL_FOR_TABLE_QUESTION_ANSWERING_MAPPING_NAMES, | |
| MODEL_FOR_TEXT_TO_SPECTROGRAM_MAPPING_NAMES, | |
| MODEL_FOR_TEXT_TO_WAVEFORM_MAPPING_NAMES, | |
| MODEL_FOR_TIME_SERIES_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_TIME_SERIES_PREDICTION_MAPPING_NAMES, | |
| MODEL_FOR_TIME_SERIES_REGRESSION_MAPPING_NAMES, | |
| MODEL_FOR_TOKEN_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_UNIVERSAL_SEGMENTATION_MAPPING_NAMES, | |
| MODEL_FOR_VIDEO_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_VISUAL_QUESTION_ANSWERING_MAPPING_NAMES, | |
| MODEL_FOR_ZERO_SHOT_IMAGE_CLASSIFICATION_MAPPING_NAMES, | |
| MODEL_FOR_ZERO_SHOT_OBJECT_DETECTION_MAPPING_NAMES, | |
| ) | |
| # Define the mapping from MODEL_FOR_* to human-readable task names | |
| task_mappings = { | |
| "text-generation": MODEL_FOR_CAUSAL_LM_MAPPING_NAMES, | |
| "text-classification": MODEL_FOR_SEQUENCE_CLASSIFICATION_MAPPING_NAMES, | |
| "token-classification": MODEL_FOR_TOKEN_CLASSIFICATION_MAPPING_NAMES, | |
| "question-answering": MODEL_FOR_QUESTION_ANSWERING_MAPPING_NAMES, | |
| "fill-mask": MODEL_FOR_MASKED_LM_MAPPING_NAMES, | |
| "text2text-generation": MODEL_FOR_SEQ_TO_SEQ_CAUSAL_LM_MAPPING_NAMES, | |
| "multiple-choice": MODEL_FOR_MULTIPLE_CHOICE_MAPPING_NAMES, | |
| "image-classification": MODEL_FOR_IMAGE_CLASSIFICATION_MAPPING_NAMES, | |
| "object-detection": MODEL_FOR_OBJECT_DETECTION_MAPPING_NAMES, | |
| "image-segmentation": MODEL_FOR_IMAGE_SEGMENTATION_MAPPING_NAMES, | |
| "semantic-segmentation": MODEL_FOR_SEMANTIC_SEGMENTATION_MAPPING_NAMES, | |
| "instance-segmentation": MODEL_FOR_INSTANCE_SEGMENTATION_MAPPING_NAMES, | |
| "universal-segmentation": MODEL_FOR_UNIVERSAL_SEGMENTATION_MAPPING_NAMES, | |
| "depth-estimation": MODEL_FOR_DEPTH_ESTIMATION_MAPPING_NAMES, | |
| "masked-image-modeling": MODEL_FOR_MASKED_IMAGE_MODELING_MAPPING_NAMES, | |
| "causal-image-modeling": MODEL_FOR_CAUSAL_IMAGE_MODELING_MAPPING_NAMES, | |
| "keypoint-detection": MODEL_FOR_KEYPOINT_DETECTION_MAPPING_NAMES, | |
| "keypoint-matching": MODEL_FOR_KEYPOINT_MATCHING_MAPPING_NAMES, | |
| "video-classification": MODEL_FOR_VIDEO_CLASSIFICATION_MAPPING_NAMES, | |
| "audio-classification": MODEL_FOR_AUDIO_CLASSIFICATION_MAPPING_NAMES, | |
| "audio-frame-classification": MODEL_FOR_AUDIO_FRAME_CLASSIFICATION_MAPPING_NAMES, | |
| "audio-xvector": MODEL_FOR_AUDIO_XVECTOR_MAPPING_NAMES, | |
| "automatic-speech-recognition": MODEL_FOR_SPEECH_SEQ_2_SEQ_MAPPING_NAMES, | |
| "connectionist-temporal-classification": MODEL_FOR_CTC_MAPPING_NAMES, | |
| "image-text-to-text": MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES, | |
| "visual-question-answering": MODEL_FOR_VISUAL_QUESTION_ANSWERING_MAPPING_NAMES, | |
| "document-question-answering": MODEL_FOR_DOCUMENT_QUESTION_ANSWERING_MAPPING_NAMES, | |
| "table-question-answering": MODEL_FOR_TABLE_QUESTION_ANSWERING_MAPPING_NAMES, | |
| "zero-shot-image-classification": MODEL_FOR_ZERO_SHOT_IMAGE_CLASSIFICATION_MAPPING_NAMES, | |
| "zero-shot-object-detection": MODEL_FOR_ZERO_SHOT_OBJECT_DETECTION_MAPPING_NAMES, | |
| "image-to-image": MODEL_FOR_IMAGE_TO_IMAGE_MAPPING_NAMES, | |
| "mask-generation": MODEL_FOR_MASK_GENERATION_MAPPING_NAMES, | |
| "text-to-audio": MODEL_FOR_TEXT_TO_SPECTROGRAM_MAPPING_NAMES, | |
| "text-to-waveform": MODEL_FOR_TEXT_TO_WAVEFORM_MAPPING_NAMES, | |
| "time-series-classification": MODEL_FOR_TIME_SERIES_CLASSIFICATION_MAPPING_NAMES, | |
| "time-series-regression": MODEL_FOR_TIME_SERIES_REGRESSION_MAPPING_NAMES, | |
| "time-series-prediction": MODEL_FOR_TIME_SERIES_PREDICTION_MAPPING_NAMES, | |
| } | |
| # Invert the mapping: model_name -> [list of tasks] | |
| model_to_tasks = {} | |
| for task_name, model_mapping in task_mappings.items(): | |
| for model_name in model_mapping.keys(): | |
| if model_name not in model_to_tasks: | |
| model_to_tasks[model_name] = [] | |
| model_to_tasks[model_name].append(task_name) | |
| self.tasks_cache = model_to_tasks | |
| print(f"✅ Loaded task mappings for {len(model_to_tasks)} models") | |
| return model_to_tasks | |
| except Exception as e: | |
| print(f"❌ Error loading task mappings: {e}") | |
| return {} | |
| def get_model_tasks(self, model_name: str) -> list[str]: | |
| """Get the list of tasks/pipelines supported by a model.""" | |
| if not self.tasks_cache: | |
| self.load_model_task_mappings() | |
| # Normalize model name (handle variations) | |
| normalized_name = model_name.lower().replace("_", "-") | |
| # Try exact match first | |
| if normalized_name in self.tasks_cache: | |
| return self.tasks_cache[normalized_name] | |
| # Try some common variations | |
| variations = [ | |
| model_name.lower(), | |
| model_name.replace("_", "-"), | |
| model_name.replace("-", "_"), | |
| ] | |
| for variation in variations: | |
| if variation in self.tasks_cache: | |
| return self.tasks_cache[variation] | |
| return [] | |
| def extract_model_title_from_file(self, file_path: str) -> str: | |
| """Extract the model title from the markdown file.""" | |
| try: | |
| with open(file_path, "r", encoding="utf-8") as f: | |
| lines = f.readlines() | |
| for line in lines: | |
| line = line.strip() | |
| if line.startswith("```python"): | |
| break | |
| if line.startswith("# ") and not line.startswith("# Overview"): | |
| title = line[2:].strip() | |
| return title | |
| except Exception: | |
| pass | |
| return os.path.basename(file_path).replace(".md", "").replace("_", " ").replace("-", " ").title() | |
| def parse_all_model_dates(self, force_refresh: bool = False) -> list[dict[str, str]]: | |
| """Parse release dates from all model documentation files.""" | |
| if self.models_cache is not None and not force_refresh: | |
| return self.models_cache | |
| models = [] | |
| pattern = os.path.join(self.docs_dir, "*.md") | |
| md_files = glob.glob(pattern) | |
| print(f"Found {len(md_files)} markdown files to process...") | |
| for file_path in md_files: | |
| result = self.parse_release_date_from_file(file_path) | |
| if result: | |
| result["display_name"] = self.extract_model_title_from_file(file_path) | |
| models.append(result) | |
| models.sort(key=lambda x: x["transformers_date"]) | |
| print(f"Found {len(models)} models with release dates") | |
| self.models_cache = models | |
| return models | |
| # Initialize Flask app | |
| app = Flask(__name__) | |
| # Dynamically find the transformers installation directory | |
| transformers_path = os.path.dirname(transformers.__file__) | |
| # Check if transformers repo exists, if not clone it | |
| repo_dir = os.path.join(os.path.dirname(__file__), "transformers_repo", "transformers") | |
| if not os.path.exists(repo_dir): | |
| print("Cloning transformers repository...") | |
| subprocess.run(["git", "clone", "https://github.com/huggingface/transformers.git", repo_dir], check=True) | |
| subprocess.run(["pip", "install", "-e", repo_dir], check=True) | |
| else: | |
| # fetch the latest changes and pull them | |
| subprocess.run(["git", "fetch", "--all", "--prune"], cwd=repo_dir, check=True) | |
| subprocess.run(["git", "pull", "origin", "main"], cwd=repo_dir, check=True) | |
| docs_dir = os.path.join(repo_dir, "docs", "source", "en", "model_doc") | |
| docs_dir = os.path.abspath(docs_dir) | |
| parser = TransformersTimelineParser(docs_dir) | |
| def index(): | |
| """Main timeline page.""" | |
| return render_template("timeline.html") | |
| def get_models(): | |
| """API endpoint to get all models with date, modality, and task filtering.""" | |
| start_date = request.args.get("start_date") | |
| end_date = request.args.get("end_date") | |
| modalities = request.args.getlist("modality") # Get list of selected modalities | |
| tasks = request.args.getlist("task") # Get list of selected tasks | |
| try: | |
| models = parser.parse_all_model_dates() | |
| # Apply modality filtering | |
| if modalities: | |
| models = [model for model in models if model["modality"] in modalities] | |
| # Apply task filtering | |
| if tasks: | |
| # Filter models that support at least one of the selected tasks | |
| models = [model for model in models if any(task in model.get("tasks", []) for task in tasks)] | |
| # Apply date filtering | |
| if start_date and end_date: | |
| filtered_models = [m for m in models if start_date <= m["transformers_date"] <= end_date] | |
| else: | |
| filtered_models = models | |
| return jsonify( | |
| { | |
| "success": True, | |
| "models": filtered_models, | |
| "total_count": len(models), | |
| "filtered_count": len(filtered_models), | |
| } | |
| ) | |
| except Exception as e: | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| def get_modalities(): | |
| """API endpoint to get available modalities.""" | |
| try: | |
| modalities = [] | |
| for key, info in parser.modalities.items(): | |
| modalities.append({"key": key, "name": info["name"], "color": info["color"]}) | |
| return jsonify({"success": True, "modalities": modalities}) | |
| except Exception as e: | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| def get_tasks(): | |
| """API endpoint to get available tasks/pipelines.""" | |
| try: | |
| # Load task mappings to get all available tasks | |
| parser.load_model_task_mappings() | |
| # Collect all unique tasks across all models | |
| all_tasks = set() | |
| for model_tasks in parser.tasks_cache.values(): | |
| all_tasks.update(model_tasks) | |
| # Define task categories and colors | |
| task_categories = { | |
| "text-generation": {"name": "Text Generation", "color": "#6366f1"}, | |
| "text-classification": {"name": "Text Classification", "color": "#8b5cf6"}, | |
| "token-classification": {"name": "Token Classification", "color": "#a855f7"}, | |
| "question-answering": {"name": "Question Answering", "color": "#c084fc"}, | |
| "fill-mask": {"name": "Fill Mask", "color": "#d8b4fe"}, | |
| "text2text-generation": {"name": "Text2Text Generation", "color": "#e879f9"}, | |
| "multiple-choice": {"name": "Multiple Choice", "color": "#c026d3"}, | |
| "image-classification": {"name": "Image Classification", "color": "#06b6d4"}, | |
| "object-detection": {"name": "Object Detection", "color": "#0891b2"}, | |
| "image-segmentation": {"name": "Image Segmentation", "color": "#0e7490"}, | |
| "semantic-segmentation": {"name": "Semantic Segmentation", "color": "#155e75"}, | |
| "instance-segmentation": {"name": "Instance Segmentation", "color": "#164e63"}, | |
| "universal-segmentation": {"name": "Universal Segmentation", "color": "#1e40af"}, | |
| "depth-estimation": {"name": "Depth Estimation", "color": "#1d4ed8"}, | |
| "masked-image-modeling": {"name": "Masked Image Modeling", "color": "#7c3aed"}, | |
| "causal-image-modeling": {"name": "Causal Image Modeling", "color": "#6d28d9"}, | |
| "keypoint-detection": {"name": "Keypoint Detection", "color": "#4338ca"}, | |
| "keypoint-matching": {"name": "Keypoint Matching", "color": "#3730a3"}, | |
| "zero-shot-image-classification": {"name": "Zero-Shot Image Classification", "color": "#2563eb"}, | |
| "zero-shot-object-detection": {"name": "Zero-Shot Object Detection", "color": "#3b82f6"}, | |
| "image-to-image": {"name": "Image to Image", "color": "#60a5fa"}, | |
| "mask-generation": {"name": "Mask Generation", "color": "#93c5fd"}, | |
| "image-to-text": {"name": "Image to Text", "color": "#10b981"}, | |
| "image-text-to-text": {"name": "Image+Text to Text", "color": "#059669"}, | |
| "visual-question-answering": {"name": "Visual Question Answering", "color": "#047857"}, | |
| "document-question-answering": {"name": "Document Question Answering", "color": "#065f46"}, | |
| "table-question-answering": {"name": "Table Question Answering", "color": "#064e3b"}, | |
| "video-classification": {"name": "Video Classification", "color": "#dc2626"}, | |
| "audio-classification": {"name": "Audio Classification", "color": "#ea580c"}, | |
| "audio-frame-classification": {"name": "Audio Frame Classification", "color": "#fb923c"}, | |
| "audio-xvector": {"name": "Audio X-Vector", "color": "#fdba74"}, | |
| "automatic-speech-recognition": {"name": "Automatic Speech Recognition", "color": "#e11d48"}, | |
| "connectionist-temporal-classification": { | |
| "name": "Connectionist Temporal Classification", | |
| "color": "#be123c", | |
| }, | |
| "text-to-audio": {"name": "Text to Audio", "color": "#f97316"}, | |
| "text-to-waveform": {"name": "Text to Waveform", "color": "#fb923c"}, | |
| "time-series-classification": {"name": "Time Series Classification", "color": "#84cc16"}, | |
| "time-series-regression": {"name": "Time Series Regression", "color": "#65a30d"}, | |
| "time-series-prediction": {"name": "Time Series Prediction", "color": "#4d7c0f"}, | |
| } | |
| # Return tasks that actually exist in the mappings | |
| available_tasks = [] | |
| for task in sorted(all_tasks): | |
| if task in task_categories: | |
| available_tasks.append( | |
| {"key": task, "name": task_categories[task]["name"], "color": task_categories[task]["color"]} | |
| ) | |
| else: | |
| # Fallback for unmapped tasks | |
| available_tasks.append( | |
| { | |
| "key": task, | |
| "name": task.replace("-", " ").title(), | |
| "color": "#6b7280", # Gray color for unmapped tasks | |
| } | |
| ) | |
| return jsonify({"success": True, "tasks": available_tasks}) | |
| except Exception as e: | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| def create_timeline_template(): | |
| """Create the HTML template for the timeline.""" | |
| template_dir = os.path.join(os.path.dirname(__file__), "templates") | |
| os.makedirs(template_dir, exist_ok=True) | |
| html_content = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>🤗 Transformers Models Timeline</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 100%); | |
| height: 100vh; | |
| overflow: hidden; | |
| color: #333; | |
| display: flex; | |
| flex-direction: column; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode body */ | |
| [data-theme="dark"] body { | |
| background: linear-gradient(135deg, #1f2937 0%, #111827 100%); | |
| color: #f9fafb; | |
| } | |
| .header { | |
| background: rgba(255, 255, 255, 0.9); | |
| backdrop-filter: blur(10px); | |
| padding: 0.8rem 1.5rem; | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.5); | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| margin: 0 0 0.3rem 0; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode styles */ | |
| [data-theme="dark"] .header { | |
| background: rgba(17, 24, 39, 0.9); | |
| border-bottom: 1px solid rgba(55, 65, 81, 0.5); | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| .header h1 { | |
| font-size: 1.6rem; | |
| font-weight: 700; | |
| color: #2d3748; | |
| margin-bottom: 0.1rem; | |
| transition: color 0.3s ease; | |
| } | |
| .header p { | |
| color: #666; | |
| font-size: 0.85rem; | |
| margin: 0; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode header text */ | |
| [data-theme="dark"] .header h1 { | |
| color: #f9fafb; | |
| } | |
| [data-theme="dark"] .header p { | |
| color: #d1d5db; | |
| } | |
| /* Header layout */ | |
| .header-content { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .header-text { | |
| flex: 1; | |
| } | |
| /* Theme toggle button */ | |
| .theme-toggle { | |
| background: rgba(255, 255, 255, 0.9); | |
| border: 2px solid #e2e8f0; | |
| border-radius: 50%; | |
| width: 48px; | |
| height: 48px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-size: 1.2rem; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |
| backdrop-filter: blur(10px); | |
| } | |
| .theme-toggle:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); | |
| border-color: #d97706; | |
| } | |
| .theme-toggle:active { | |
| transform: translateY(0); | |
| } | |
| .theme-icon { | |
| transition: transform 0.3s ease; | |
| } | |
| /* Dark mode theme toggle */ | |
| [data-theme="dark"] .theme-toggle { | |
| background: rgba(17, 24, 39, 0.9); | |
| border-color: #4b5563; | |
| color: #f9fafb; | |
| } | |
| [data-theme="dark"] .theme-toggle:hover { | |
| border-color: #f59e0b; | |
| } | |
| .controls-wrapper { | |
| margin: 0.3rem 2rem; | |
| } | |
| .controls-toggle { | |
| position: absolute; | |
| left: 8px; | |
| right: 8px; | |
| top: 8px; | |
| z-index: 2; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 28px; | |
| height: 28px; | |
| font-size: 0.9rem; | |
| border-radius: 999px; | |
| border: 1px solid #e5e7eb; | |
| background: #ffffff; | |
| color: #374151; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.06); | |
| } | |
| /* Dark mode controls toggle */ | |
| [data-theme="dark"] .controls-toggle { | |
| background: #374151; | |
| border: 1px solid #4b5563; | |
| color: #f9fafb; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.3); | |
| } | |
| .controls-toggle:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 14px rgba(0,0,0,0.1); | |
| } | |
| /* Dark mode controls toggle hover */ | |
| [data-theme="dark"] .controls-toggle:hover { | |
| background: #4b5563; | |
| border-color: #6b7280; | |
| box-shadow: 0 4px 14px rgba(0,0,0,0.4); | |
| } | |
| .controls { | |
| background: rgba(255, 255, 255, 0.9); | |
| backdrop-filter: blur(10px); | |
| padding: 0.4rem 1rem; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); | |
| display: flex; | |
| gap: 0.8rem; | |
| align-items: start; | |
| flex-wrap: nowrap; | |
| overflow: hidden; | |
| transition: max-height 0.25s ease, opacity 0.2s ease, background 0.3s ease, box-shadow 0.3s ease; | |
| position: relative; | |
| padding-left: 2.4rem; /* space for the left toggle button */ | |
| max-height: 74px; /* default collapsed height */ | |
| } | |
| /* Dark mode controls */ | |
| [data-theme="dark"] .controls { | |
| background: rgba(17, 24, 39, 0.9); | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| .controls.collapsed { | |
| opacity: 1; | |
| max-height: 74px !important; | |
| } | |
| /* Blur fade to hint there is more content when collapsed */ | |
| .controls.collapsed::after { | |
| content: ''; | |
| position: absolute; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| height: 26px; | |
| background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0.94) 60%, rgba(255,255,255,1)); | |
| pointer-events: none; | |
| } | |
| /* Dark mode blur fade */ | |
| [data-theme="dark"] .controls.collapsed::after { | |
| background: linear-gradient(to bottom, rgba(17,24,39,0), rgba(17,24,39,0.94) 60%, rgba(17,24,39,1)); | |
| } | |
| .input-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.2rem; | |
| min-width: 120px; | |
| } | |
| .input-group label { | |
| font-weight: 500; | |
| color: #4a5568; | |
| font-size: 0.75rem; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode labels */ | |
| [data-theme="dark"] .input-group label { | |
| color: #d1d5db; | |
| } | |
| .input-group input { | |
| padding: 0.35rem 0.65rem; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 5px; | |
| font-size: 0.75rem; | |
| transition: all 0.2s ease; | |
| background: white; | |
| } | |
| /* Dark mode input elements */ | |
| [data-theme="dark"] .input-group input { | |
| background: #374151; | |
| border-color: #4b5563; | |
| color: #f9fafb; | |
| } | |
| [data-theme="dark"] .input-group input:focus { | |
| border-color: #f59e0b; | |
| box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1); | |
| } | |
| .input-group input:focus { | |
| outline: none; | |
| border-color: #d97706; | |
| box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.1); | |
| } | |
| .modality-group { | |
| flex: 22%; | |
| min-width: 220px; | |
| padding-left: 8px; /* add small spacing from collapse arrow */ | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .modality-filters { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.25rem; | |
| margin-top: 0.2rem; | |
| flex: 1; | |
| } | |
| .modality-buttons { | |
| display: flex; | |
| gap: 0.3rem; | |
| margin-top: 0.3rem; | |
| margin-bottom: 0.5rem; | |
| align-items: flex-end; | |
| } | |
| .task-group { | |
| flex: 78%; | |
| min-width: 520px; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .task-filters { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.25rem; | |
| margin-top: 0.2rem; | |
| flex: 1; | |
| } | |
| .task-buttons { | |
| display: flex; | |
| gap: 0.3rem; | |
| margin-top: 0.3rem; | |
| margin-bottom: 0.5rem; | |
| align-items: flex-end; | |
| } | |
| .btn-small { | |
| padding: 0.45rem 0.65rem; | |
| font-size: 0.7rem; | |
| margin-top: 0; | |
| height: 34px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modality-checkbox { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.35rem; | |
| padding: 0.35rem 0.55rem; | |
| background: rgba(255, 255, 255, 0.9); | |
| border: 1px solid rgba(0, 0, 0, 0.1); | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| font-size: 0.7rem; | |
| font-weight: 500; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| /* Dark mode modality checkboxes */ | |
| [data-theme="dark"] .modality-checkbox { | |
| background: rgba(17, 24, 39, 0.9); | |
| border: 1px solid rgba(55, 65, 81, 0.5); | |
| } | |
| .modality-checkbox::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: currentColor; | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .modality-checkbox:hover { | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); | |
| border-color: currentColor; | |
| } | |
| .modality-checkbox:hover::before { | |
| opacity: 0.05; | |
| } | |
| .modality-checkbox.checked { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-color: currentColor; | |
| } | |
| /* Dark mode checked modality checkbox cards */ | |
| [data-theme="dark"] .modality-checkbox.checked { | |
| background: rgba(31, 41, 55, 0.95); | |
| border-color: currentColor; | |
| } | |
| .modality-checkbox.checked::before { | |
| opacity: 0.08; | |
| } | |
| .modality-checkbox input[type="checkbox"] { | |
| appearance: none; | |
| width: 17px; | |
| height: 17px; | |
| border: 2px solid var(--modality-color, #8B5CF6); | |
| border-radius: 4px; | |
| position: relative; | |
| margin: 0; | |
| background: white; | |
| transition: all 0.2s ease; | |
| flex-shrink: 0; | |
| } | |
| /* Dark mode unchecked checkboxes */ | |
| [data-theme="dark"] .modality-checkbox input[type="checkbox"] { | |
| background: #374151; | |
| border-color: var(--modality-color, #8B5CF6); | |
| } | |
| .modality-checkbox input[type="checkbox"]:checked { | |
| background: var(--modality-color, #8B5CF6); | |
| border-color: var(--modality-color, #8B5CF6); | |
| } | |
| /* Dark mode checked checkboxes */ | |
| [data-theme="dark"] .modality-checkbox input[type="checkbox"]:checked { | |
| background: var(--modality-color, #8B5CF6); | |
| border-color: var(--modality-color, #8B5CF6); | |
| } | |
| .modality-checkbox input[type="checkbox"]:checked::after { | |
| content: '✓'; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 10px; | |
| font-weight: bold; | |
| line-height: 1; | |
| } | |
| .modality-checkbox label { | |
| cursor: pointer; | |
| user-select: none; | |
| color: #374151; | |
| font-weight: 600; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode modality checkbox labels */ | |
| [data-theme="dark"] .modality-checkbox label { | |
| color: #f9fafb; | |
| } | |
| /* Task filter styles (mirroring modality styles) */ | |
| .task-checkbox { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.35rem; | |
| padding: 0.35rem 0.55rem; | |
| background: rgba(255, 255, 255, 0.9); | |
| border: 1px solid rgba(0, 0, 0, 0.1); | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| font-size: 0.7rem; | |
| font-weight: 500; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| /* Dark mode task checkboxes */ | |
| [data-theme="dark"] .task-checkbox { | |
| background: rgba(17, 24, 39, 0.9); | |
| border: 1px solid rgba(55, 65, 81, 0.5); | |
| } | |
| .task-checkbox::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: currentColor; | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .task-checkbox:hover { | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); | |
| border-color: currentColor; | |
| } | |
| .task-checkbox:hover::before { | |
| opacity: 0.05; | |
| } | |
| .task-checkbox.checked { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-color: currentColor; | |
| } | |
| /* Dark mode checked task checkbox cards */ | |
| [data-theme="dark"] .task-checkbox.checked { | |
| background: rgba(31, 41, 55, 0.95); | |
| border-color: currentColor; | |
| } | |
| .task-checkbox.checked::before { | |
| opacity: 0.08; | |
| } | |
| .task-checkbox input[type="checkbox"] { | |
| appearance: none; | |
| width: 17px; | |
| height: 17px; | |
| border: 2px solid var(--task-color, #6366f1); | |
| border-radius: 4px; | |
| background: white; | |
| cursor: pointer; | |
| position: relative; | |
| transition: all 0.2s ease; | |
| } | |
| /* Dark mode unchecked task checkboxes */ | |
| [data-theme="dark"] .task-checkbox input[type="checkbox"] { | |
| background: #374151; | |
| border-color: var(--task-color, #6366f1); | |
| } | |
| .task-checkbox input[type="checkbox"]:checked { | |
| background: var(--task-color, #6366f1); | |
| border-color: var(--task-color, #6366f1); | |
| } | |
| /* Dark mode checked task checkboxes */ | |
| [data-theme="dark"] .task-checkbox input[type="checkbox"]:checked { | |
| background: var(--task-color, #6366f1); | |
| border-color: var(--task-color, #6366f1); | |
| } | |
| .task-checkbox input[type="checkbox"]:checked::after { | |
| content: '✓'; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 10px; | |
| font-weight: bold; | |
| line-height: 1; | |
| } | |
| .task-checkbox label { | |
| cursor: pointer; | |
| user-select: none; | |
| color: #374151; | |
| font-weight: 600; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode task checkbox labels */ | |
| [data-theme="dark"] .task-checkbox label { | |
| color: #f9fafb; | |
| } | |
| .modality-checkbox input[type="checkbox"]:not(:checked) { | |
| background: white; | |
| border-color: var(--modality-color, #8B5CF6); | |
| } | |
| /* Dark mode unchecked checkboxes override */ | |
| [data-theme="dark"] .modality-checkbox input[type="checkbox"]:not(:checked) { | |
| background: #374151; | |
| border-color: var(--modality-color, #8B5CF6); | |
| } | |
| .btn { | |
| padding: 0.5rem 1rem; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| margin-top: 1.1rem; | |
| align-self: flex-start; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); | |
| color: white; | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 20px rgba(217, 119, 6, 0.3); | |
| } | |
| .btn-secondary { | |
| background: #f7fafc; | |
| color: #4a5568; | |
| border: 2px solid #e2e8f0; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode secondary buttons */ | |
| [data-theme="dark"] .btn-secondary { | |
| background: #374151; | |
| color: #f9fafb; | |
| border: 2px solid #4b5563; | |
| } | |
| [data-theme="dark"] .btn-secondary:hover { | |
| background: #4b5563; | |
| border-color: #6b7280; | |
| } | |
| .timeline-container { | |
| margin: 0.5rem 2rem 1rem 2rem; | |
| background: rgba(255, 255, 255, 0.95); | |
| backdrop-filter: blur(10px); | |
| border-radius: 16px; | |
| padding: 1rem; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); | |
| position: relative; | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 400px; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode timeline container */ | |
| [data-theme="dark"] .timeline-container { | |
| background: rgba(17, 24, 39, 0.95); | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); | |
| } | |
| .timeline-wrapper { | |
| position: relative; | |
| flex: 1; | |
| overflow: hidden; | |
| display: flex; | |
| align-items: center; | |
| cursor: grab; | |
| user-select: none; | |
| } | |
| .timeline-wrapper:active { | |
| cursor: grabbing; | |
| } | |
| .timeline-scroll { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| cursor: inherit; | |
| user-select: none; | |
| } | |
| .timeline-scroll::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .timeline { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .nav-arrow { | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 40px; | |
| height: 40px; | |
| background: rgba(255, 255, 255, 0.9); | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| font-size: 1.2rem; | |
| color: #d97706; | |
| border: 2px solid rgba(217, 119, 6, 0.2); | |
| backdrop-filter: blur(10px); | |
| transition: all 0.2s ease; | |
| z-index: 10; | |
| } | |
| /* Dark mode navigation arrows */ | |
| [data-theme="dark"] .nav-arrow { | |
| background: rgba(17, 24, 39, 0.9); | |
| color: #f59e0b; | |
| border: 2px solid rgba(245, 158, 11, 0.2); | |
| } | |
| .nav-arrow:hover { | |
| background: rgba(217, 119, 6, 0.1); | |
| border-color: #d97706; | |
| transform: translateY(-50%) scale(1.1); | |
| box-shadow: 0 4px 16px rgba(217, 119, 6, 0.3); | |
| } | |
| /* Dark mode navigation arrow hover */ | |
| [data-theme="dark"] .nav-arrow:hover { | |
| background: rgba(245, 158, 11, 0.1); | |
| border-color: #f59e0b; | |
| box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3); | |
| } | |
| .nav-arrow.left { | |
| left: 10px; | |
| } | |
| .nav-arrow.right { | |
| right: 10px; | |
| } | |
| .nav-arrow:disabled { | |
| opacity: 0.3; | |
| cursor: not-allowed; | |
| transform: translateY(-50%) scale(0.9); | |
| } | |
| .zoom-controls { | |
| position: absolute; | |
| top: 15px; | |
| right: 15px; | |
| display: flex; | |
| gap: 6px; | |
| z-index: 20; | |
| } | |
| .zoom-btn { | |
| width: 28px; | |
| height: 28px; | |
| background: rgba(255, 255, 255, 0.9); | |
| border-radius: 6px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| color: #d97706; | |
| border: 2px solid rgba(217, 119, 6, 0.2); | |
| backdrop-filter: blur(10px); | |
| transition: all 0.2s ease; | |
| user-select: none; | |
| } | |
| /* Dark mode zoom buttons */ | |
| [data-theme="dark"] .zoom-btn { | |
| background: rgba(17, 24, 39, 0.9); | |
| border: 2px solid rgba(245, 158, 11, 0.2); | |
| color: #f59e0b; | |
| } | |
| .zoom-btn:hover { | |
| background: rgba(217, 119, 6, 0.1); | |
| border-color: #d97706; | |
| transform: scale(1.1); | |
| box-shadow: 0 4px 16px rgba(217, 119, 6, 0.3); | |
| } | |
| /* Dark mode zoom button hover */ | |
| [data-theme="dark"] .zoom-btn:hover { | |
| background: rgba(245, 158, 11, 0.1); | |
| border-color: #f59e0b; | |
| box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3); | |
| } | |
| .zoom-btn:active { | |
| transform: scale(0.95); | |
| } | |
| .zoom-indicator { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-radius: 6px; | |
| padding: 2px 6px; | |
| font-size: 0.7rem; | |
| color: #d97706; | |
| font-weight: 500; | |
| border: 1px solid rgba(217, 119, 6, 0.2); | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 40px; | |
| height: 28px; | |
| } | |
| /* Dark mode zoom indicator */ | |
| [data-theme="dark"] .zoom-indicator { | |
| background: rgba(17, 24, 39, 0.95); | |
| color: #f59e0b; | |
| border: 1px solid rgba(245, 158, 11, 0.2); | |
| } | |
| .timeline-line { | |
| position: absolute; | |
| top: 50%; | |
| left: 0; | |
| right: 0; | |
| height: 4px; | |
| background: linear-gradient(90deg, #fbbf24, #d97706); | |
| border-radius: 2px; | |
| transform: translateY(-50%); | |
| } | |
| .timeline-item { | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| } | |
| .timeline-item:hover { | |
| transform: translateY(-50%); | |
| z-index: 10; | |
| } | |
| .timeline-connector { | |
| position: absolute; | |
| width: 1px; | |
| background: #d2d6dc; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| opacity: 0.6; | |
| z-index: 1; | |
| } | |
| .date-marker { | |
| position: absolute; | |
| top: 0; | |
| bottom: 0; | |
| width: 1px; | |
| border-left: 1px dashed #9ca3af; | |
| opacity: 0.5; | |
| pointer-events: none; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode date markers */ | |
| [data-theme="dark"] .date-marker { | |
| border-left-color: #6b7280; | |
| opacity: 0.6; | |
| } | |
| .date-label { | |
| position: absolute; | |
| top: 10px; /* Near the top of timeline viewport */ | |
| left: 8px; /* Offset to the right of the line */ | |
| font-size: 0.75rem; | |
| color: #6b7280; | |
| font-weight: 500; | |
| background: rgba(255, 255, 255, 0.8); | |
| padding: 4px 8px; | |
| border-radius: 6px; | |
| pointer-events: none; | |
| border: 1px solid rgba(156, 163, 175, 0.4); | |
| box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); | |
| white-space: nowrap; | |
| z-index: 9; /* Just above the dotted line but below cards */ | |
| backdrop-filter: blur(4px); | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode date labels */ | |
| [data-theme="dark"] .date-label { | |
| color: #d1d5db; | |
| background: rgba(17, 24, 39, 0.8); | |
| border: 1px solid rgba(75, 85, 99, 0.4); | |
| box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); | |
| } | |
| .date-marker.year { | |
| opacity: 0.7; | |
| border-left: 1px dashed #6b7280; | |
| width: 1px; | |
| } | |
| /* Dark mode year markers */ | |
| [data-theme="dark"] .date-marker.year { | |
| border-left-color: #9ca3af; | |
| opacity: 0.8; | |
| } | |
| .date-marker.year .date-label { | |
| font-weight: 600; | |
| color: #4b5563; | |
| background: rgba(255, 255, 255, 0.9); | |
| font-size: 0.8rem; | |
| border-color: #9ca3af; | |
| } | |
| /* Dark mode year date labels */ | |
| [data-theme="dark"] .date-marker.year .date-label { | |
| color: #f9fafb; | |
| background: rgba(17, 24, 39, 0.9); | |
| border-color: #6b7280; | |
| } | |
| .date-marker.quarter { | |
| opacity: 0.7; | |
| } | |
| .date-marker.month { | |
| opacity: 0.8; | |
| } | |
| /* Dark mode quarter and month markers */ | |
| [data-theme="dark"] .date-marker.quarter { | |
| opacity: 0.8; | |
| } | |
| [data-theme="dark"] .date-marker.month { | |
| opacity: 0.9; | |
| } | |
| .timeline-dot { | |
| width: 19px; | |
| height: 19px; | |
| border-radius: 50%; | |
| background: white; | |
| border: 3px solid; | |
| position: relative; | |
| margin: 0 auto; | |
| box-shadow: 0 3px 12px rgba(0, 0, 0, 0.2); | |
| z-index: 5; | |
| } | |
| .timeline-item:nth-child(odd) .timeline-dot { | |
| border-color: #fbbf24; | |
| } | |
| .timeline-item:nth-child(even) .timeline-dot { | |
| border-color: #d97706; | |
| } | |
| .timeline-label { | |
| position: absolute; | |
| min-width: 110px; | |
| max-width: 170px; | |
| padding: 0.5rem 0.7rem; | |
| background: white; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| font-weight: 500; | |
| text-align: center; | |
| box-shadow: 0 3px 12px rgba(0, 0, 0, 0.12); | |
| border: 1px solid #e2e8f0; | |
| line-height: 1.2; | |
| word-break: break-word; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 6; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| cursor: pointer; | |
| } | |
| /* Dark mode timeline labels */ | |
| [data-theme="dark"] .timeline-label { | |
| background: #374151; | |
| border: 1px solid #4b5563; | |
| color: #f9fafb; | |
| box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); | |
| } | |
| .timeline-label:not(.expanded):hover { | |
| background: rgba(255, 255, 255, 0.98); | |
| box-shadow: 0 6px 25px rgba(0, 0, 0, 0.15), | |
| 0 0 4px var(--modality-color, #8B5CF6); | |
| border-color: var(--modality-color, #8B5CF6); | |
| } | |
| /* Dark mode timeline label hover */ | |
| [data-theme="dark"] .timeline-label:not(.expanded):hover { | |
| background: rgba(55, 65, 81, 0.98); | |
| box-shadow: 0 6px 25px rgba(0, 0, 0, 0.4), | |
| 0 0 4px var(--modality-color, #8B5CF6); | |
| border-color: var(--modality-color, #8B5CF6); | |
| } | |
| /* Expanded card styles */ | |
| .timeline-label.expanded { | |
| max-width: 550px !important; | |
| min-width: 450px !important; | |
| width: 550px !important; | |
| min-height: 250px !important; | |
| text-align: left; | |
| z-index: 9999 !important; /* Much higher than all other elements */ | |
| transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); | |
| user-select: text; | |
| } | |
| /* Cards above axis - expand downward with TOP edge fixed */ | |
| .timeline-item.above-1 .timeline-label.expanded, | |
| .timeline-item.above-2 .timeline-label.expanded, | |
| .timeline-item.above-3 .timeline-label.expanded { | |
| /* JavaScript will set the exact top position to keep top edge fixed */ | |
| transform: translateX(-50%); | |
| } | |
| /* Cards below axis - expand upward with BOTTOM edge fixed */ | |
| .timeline-item.below-1 .timeline-label.expanded, | |
| .timeline-item.below-2 .timeline-label.expanded, | |
| .timeline-item.below-3 .timeline-label.expanded { | |
| /* JavaScript will set the exact bottom position to keep bottom edge fixed */ | |
| transform: translateX(-50%); | |
| } | |
| .timeline-label .model-title { | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| margin-bottom: 0.5rem; | |
| color: #1f2937; | |
| border-bottom: 1px solid rgba(0, 0, 0, 0.1); | |
| padding-bottom: 0.3rem; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode model title */ | |
| [data-theme="dark"] .timeline-label .model-title { | |
| color: #f9fafb; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .timeline-label .model-description { | |
| font-size: 0.8rem; | |
| line-height: 1.4; | |
| color: #4b5563; | |
| max-height: 0; | |
| overflow: hidden; | |
| transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), color 0.3s ease; | |
| position: relative; | |
| } | |
| /* Dark mode model description */ | |
| [data-theme="dark"] .timeline-label .model-description { | |
| color: #d1d5db; | |
| } | |
| .timeline-label.expanded .model-description { | |
| max-height: 175px !important; | |
| height: 175px !important; | |
| position: relative; | |
| overflow: hidden; /* Hide overflow for blur effect */ | |
| } | |
| .timeline-label .description-content { | |
| max-height: 0; | |
| overflow: hidden; | |
| transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .timeline-label.expanded .description-content { | |
| position: relative; | |
| bottom: 2px; | |
| max-height: 175px !important; | |
| height: 175px !important; | |
| overflow-y: auto; | |
| padding-right: 8px; /* Space for scrollbar */ | |
| } | |
| .timeline-label .description-fade { | |
| position: sticky; | |
| bottom: 0px; /* Position at the very bottom of the container */ | |
| left: 0; | |
| height: 15px; | |
| background: linear-gradient(to bottom, | |
| rgba(255, 255, 255, 0) 0%, | |
| rgba(255, 255, 255, 0.9) 50%, | |
| rgba(255, 255, 255, 1) 95%); | |
| rgba(255, 255, 255, 1) 100%); | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| /* Dark mode description fade */ | |
| [data-theme="dark"] .timeline-label .description-fade { | |
| background: linear-gradient(to bottom, | |
| rgba(55, 65, 81, 0) 0%, | |
| rgba(55, 65, 81, 0.9) 50%, | |
| rgba(55, 65, 81, 1) 95%); | |
| rgba(55, 65, 81, 1) 100%); | |
| } | |
| .timeline-label.expanded .description-fade { | |
| opacity: 1; | |
| } | |
| /* Markdown-style formatting */ | |
| .timeline-label .model-description h1, | |
| .timeline-label .model-description h2, | |
| .timeline-label .model-description h3 { | |
| font-weight: 600; | |
| margin: 0.8em 0 0.4em 0; | |
| color: #1f2937; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode markdown headers */ | |
| [data-theme="dark"] .timeline-label .model-description h1, | |
| [data-theme="dark"] .timeline-label .model-description h2, | |
| [data-theme="dark"] .timeline-label .model-description h3 { | |
| color: #f9fafb; | |
| } | |
| .timeline-label .model-description h1 { font-size: 0.9rem; } | |
| .timeline-label .model-description h2 { font-size: 0.85rem; } | |
| .timeline-label .model-description h3 { font-size: 0.8rem; } | |
| .timeline-label .model-description p { | |
| margin: 0.5em 0; | |
| } | |
| .timeline-label .model-description code { | |
| background: #f3f4f6; | |
| padding: 0.1em 0.3em; | |
| border-radius: 3px; | |
| font-family: 'Consolas', 'Monaco', monospace; | |
| font-size: 0.7rem; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode code */ | |
| [data-theme="dark"] .timeline-label .model-description code { | |
| background: #374151; | |
| color: #f9fafb; | |
| } | |
| .timeline-label .model-description strong { | |
| font-weight: 600; | |
| color: #1f2937; | |
| transition: color 0.3s ease; | |
| } | |
| .timeline-label .model-description em { | |
| font-style: italic; | |
| } | |
| /* Dark mode strong and em */ | |
| [data-theme="dark"] .timeline-label .model-description strong { | |
| color: #f9fafb; | |
| } | |
| .timeline-label .model-description ul, | |
| .timeline-label .model-description ol { | |
| margin: 0.5em 0; | |
| padding-left: 1.2em; | |
| } | |
| .timeline-label .model-description li { | |
| margin: 0.2em 0; | |
| } | |
| .timeline-label .model-description a { | |
| color: var(--modality-color, #8B5CF6); | |
| text-decoration: underline; | |
| font-weight: 500; | |
| } | |
| .timeline-label .model-description a:hover { | |
| color: #1f2937; | |
| text-decoration: none; | |
| } | |
| .timeline-label .learn-more { | |
| display: none; | |
| text-decoration: none; | |
| color: var(--modality-color, #8B5CF6); | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| padding: 0.3rem 0.6rem; | |
| border: 1px solid var(--modality-color, #8B5CF6); | |
| border-radius: 4px; | |
| background: rgba(255, 255, 255, 0.8); | |
| transition: all 0.2s ease; | |
| margin-top: 0.2rem; | |
| text-align: center; | |
| } | |
| /* Dark mode learn more button */ | |
| [data-theme="dark"] .timeline-label .learn-more { | |
| background: rgba(55, 65, 81, 0.8); | |
| } | |
| .timeline-label.expanded .learn-more { | |
| display: inline-block; | |
| } | |
| .timeline-label .learn-more:hover { | |
| background: var(--modality-color, #8B5CF6); | |
| color: white; | |
| transform: translateY(-1px); | |
| } | |
| .timeline-label.expanded .learn-more { | |
| pointer-events: auto; | |
| cursor: pointer; | |
| } | |
| .timeline-label.expanded .model-description a { | |
| pointer-events: auto; | |
| cursor: pointer; | |
| } | |
| .timeline-label.expanded .description-content { | |
| pointer-events: auto; | |
| cursor: text; | |
| } | |
| .timeline-label.expanded .model-tasks { | |
| pointer-events: none; | |
| cursor: default; | |
| } | |
| .model-tasks { | |
| margin: 0.5rem 0; | |
| padding: 0.4rem 0.6rem; | |
| background: rgba(248, 250, 252, 0.8); | |
| border-radius: 8px; | |
| border: 1px solid rgba(226, 232, 240, 0.5); | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode model tasks */ | |
| [data-theme="dark"] .model-tasks { | |
| background: rgba(31, 41, 55, 0.8); | |
| border: 1px solid rgba(55, 65, 81, 0.5); | |
| } | |
| .tasks-label { | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| color: #4a5568; | |
| margin-bottom: 0.3rem; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode tasks label */ | |
| [data-theme="dark"] .tasks-label { | |
| color: #d1d5db; | |
| } | |
| .tasks-list { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.3rem; | |
| } | |
| .task-badge { | |
| display: inline-block; | |
| padding: 0.25rem 0.45rem; | |
| color: white; | |
| border-radius: 4px; | |
| font-size: 0.7rem; | |
| font-weight: 500; | |
| opacity: 0.9; | |
| transition: all 0.2s ease; | |
| } | |
| .task-badge:hover { | |
| opacity: 1; | |
| transform: scale(1.05); | |
| } | |
| /* Above the axis - wave pattern */ | |
| .timeline-item.above-1 .timeline-label { | |
| bottom: 60px; | |
| border-left: 3px solid #fbbf24; | |
| } | |
| .timeline-item.above-1 .timeline-connector { | |
| bottom: 9px; | |
| height: 60px; | |
| } | |
| .timeline-item.above-2 .timeline-label { | |
| bottom: 120px; | |
| border-left: 3px solid #fbbf24; | |
| } | |
| .timeline-item.above-2 .timeline-connector { | |
| bottom: 9px; | |
| height: 120px; | |
| } | |
| .timeline-item.above-3 .timeline-label { | |
| bottom: 180px; | |
| border-left: 3px solid #fbbf24; | |
| } | |
| .timeline-item.above-3 .timeline-connector { | |
| bottom: 9px; | |
| height: 180px; | |
| } | |
| /* Below the axis - wave pattern */ | |
| .timeline-item.below-1 .timeline-label { | |
| top: 60px; | |
| border-left: 3px solid #d97706; | |
| } | |
| .timeline-item.below-1 .timeline-connector { | |
| top: 9px; | |
| height: 60px; | |
| } | |
| .timeline-item.below-2 .timeline-label { | |
| top: 120px; | |
| border-left: 3px solid #d97706; | |
| } | |
| .timeline-item.below-2 .timeline-connector { | |
| top: 9px; | |
| height: 120px; | |
| } | |
| .timeline-item.below-3 .timeline-label { | |
| top: 180px; | |
| border-left: 3px solid #d97706; | |
| } | |
| .timeline-item.below-3 .timeline-connector { | |
| top: 9px; | |
| height: 180px; | |
| } | |
| .timeline-date { | |
| font-size: 0.75rem; | |
| color: #9ca3af; | |
| margin-top: 0.3rem; | |
| font-weight: 500; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode timeline date */ | |
| [data-theme="dark"] .timeline-date { | |
| color: #9ca3af; | |
| } | |
| .date-controls { | |
| display: flex; | |
| gap: 0.8rem; | |
| margin-left: auto; | |
| background: rgba(255, 255, 255, 0.95); | |
| backdrop-filter: blur(10px); | |
| padding: 0.6rem 1rem; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); | |
| border: 1px solid rgba(255, 255, 255, 0.5); | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode date controls */ | |
| [data-theme="dark"] .date-controls { | |
| background: rgba(17, 24, 39, 0.95); | |
| border: 1px solid rgba(55, 65, 81, 0.5); | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); | |
| } | |
| .date-input-group { | |
| display: flex; | |
| flex-direction: row; | |
| gap: 0.5rem; | |
| align-items: center; | |
| } | |
| .date-input-group label { | |
| font-size: 0.75rem; | |
| color: #4a5568; | |
| font-weight: 600; | |
| white-space: nowrap; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode date labels */ | |
| [data-theme="dark"] .date-input-group label { | |
| color: #d1d5db; | |
| } | |
| .date-input-group input { | |
| padding: 0.45rem 0.65rem; | |
| border: 2px solid #e2e8f0; | |
| border-radius: 8px; | |
| font-size: 0.8rem; | |
| background: white; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| min-width: 135px; | |
| text-align: center; | |
| font-weight: 500; | |
| color: #374151; | |
| } | |
| /* Dark mode date inputs */ | |
| [data-theme="dark"] .date-input-group input { | |
| background: #374151; | |
| border-color: #4b5563; | |
| color: #f9fafb; | |
| } | |
| [data-theme="dark"] .date-input-group input:hover { | |
| border-color: #6b7280; | |
| } | |
| [data-theme="dark"] .date-input-group input:focus { | |
| border-color: #f59e0b; | |
| box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15); | |
| } | |
| .date-input-group input:hover { | |
| border-color: #cbd5e0; | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| } | |
| .date-input-group input:focus { | |
| outline: none; | |
| border-color: #d97706; | |
| box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.15); | |
| transform: translateY(-1px); | |
| } | |
| .stats { | |
| display: flex; | |
| gap: 0.6rem; | |
| margin: 0.3rem 2rem 2rem 2rem; /* Added bottom margin for page */ | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .stat-card { | |
| background: rgba(255, 255, 255, 0.9); | |
| backdrop-filter: blur(10px); | |
| padding: 0.4rem 0.8rem; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); | |
| flex: 1; | |
| min-width: 140px; | |
| text-align: center; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode stat cards */ | |
| [data-theme="dark"] .stat-card { | |
| background: rgba(17, 24, 39, 0.9); | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| .stat-number { | |
| font-size: 1.2rem; | |
| font-weight: 600; | |
| color: #d97706; | |
| display: block; | |
| } | |
| .stat-label { | |
| color: #666; | |
| font-size: 0.75rem; | |
| margin-top: 0.15rem; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode stat labels */ | |
| [data-theme="dark"] .stat-label { | |
| color: #9ca3af; | |
| } | |
| .loading { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| height: 200px; | |
| font-size: 1.1rem; | |
| color: #666; | |
| transition: color 0.3s ease; | |
| } | |
| /* Dark mode loading */ | |
| [data-theme="dark"] .loading { | |
| color: #9ca3af; | |
| } | |
| .loading::after { | |
| content: ''; | |
| width: 20px; | |
| height: 20px; | |
| border: 2px solid #e2e8f0; | |
| border-top: 2px solid #d97706; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| margin-left: 1rem; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .error { | |
| text-align: center; | |
| padding: 2rem; | |
| color: #e53e3e; | |
| background: #fed7d7; | |
| border-radius: 8px; | |
| margin: 2rem; | |
| transition: all 0.3s ease; | |
| } | |
| /* Dark mode error */ | |
| [data-theme="dark"] .error { | |
| color: #fca5a5; | |
| background: #7f1d1d; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div class="header-content"> | |
| <div class="header-text"> | |
| <h1>🤗 Transformers Models Timeline</h1> | |
| <p>Interactive timeline to explore models supported by the Hugging Face Transformers library!</p> | |
| </div> | |
| <button id="themeToggle" class="theme-toggle" title="Toggle dark/light mode"> | |
| <span class="theme-icon">🌙</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="controls-wrapper"> | |
| <div class="controls collapsed" id="filtersPanel"> | |
| <button id="toggleFilters" class="controls-toggle" type="button" aria-label="Toggle filters" onclick="toggleFilters()">▸</button> | |
| <div class="input-group modality-group"> | |
| <label>Modalities</label> | |
| <div class="modality-filters" id="modalityFilters"> | |
| <!-- Modality checkboxes will be populated by JavaScript --> | |
| </div> | |
| <div class="modality-buttons"> | |
| <button class="btn btn-primary btn-small" onclick="checkAllModalities()"> | |
| Check All | |
| </button> | |
| <button class="btn btn-secondary btn-small" onclick="clearAllModalities()"> | |
| Clear All | |
| </button> | |
| </div> | |
| </div> | |
| <div class="input-group task-group"> | |
| <label>Tasks/Pipelines</label> | |
| <div class="task-filters" id="taskFilters"> | |
| <!-- Task checkboxes will be populated by JavaScript --> | |
| </div> | |
| <div class="task-buttons"> | |
| <button class="btn btn-primary btn-small" onclick="checkAllTasks()"> | |
| Check All | |
| </button> | |
| <button class="btn btn-secondary btn-small" onclick="clearAllTasks()"> | |
| Clear All | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="timeline-container"> | |
| <div class="timeline-wrapper"> | |
| <div class="nav-arrow left" id="navLeft">‹</div> | |
| <div class="nav-arrow right" id="navRight">›</div> | |
| <div class="zoom-controls"> | |
| <div class="zoom-btn" id="zoomOut" title="Zoom out (show more models)">−</div> | |
| <div class="zoom-indicator" id="zoomLevel">100%</div> | |
| <div class="zoom-btn" id="zoomIn" title="Zoom in (spread models apart)">+</div> | |
| </div> | |
| <div class="timeline-scroll" id="timelineScroll"> | |
| <div class="timeline" id="timeline"> | |
| <div class="loading">Loading timeline...</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="stats" id="stats"> | |
| <div class="stat-card"> | |
| <span class="stat-number" id="totalCount">-</span> | |
| <div class="stat-label">Total Models</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-number" id="displayedCount">-</span> | |
| <div class="stat-label">Displayed Models</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-number" id="dateRange">-</span> | |
| <div class="stat-label">Date Range</div> | |
| </div> | |
| <div class="date-controls"> | |
| <div class="date-input-group"> | |
| <label for="startDate">Start Date</label> | |
| <input type="date" id="startDate" /> | |
| </div> | |
| <div class="date-input-group"> | |
| <label for="endDate">End Date</label> | |
| <input type="date" id="endDate" /> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let allModels = []; | |
| let currentModels = []; | |
| let timelineOffset = 0; | |
| let timelineWidth = 0; | |
| let containerWidth = 0; | |
| let dateInitialized = false; // initialize date inputs once from data | |
| let isDragging = false; | |
| let startX = 0; | |
| let startOffset = 0; | |
| let zoomLevel = 1.0; // 1.0 = 100%, 0.5 = 50%, 2.0 = 200% | |
| const minZoom = 0.3; // Minimum zoom (30%) | |
| const maxZoom = 3.0; // Maximum zoom (300%) | |
| let taskData = {}; // Store task data for easy lookup | |
| // Function to calculate dynamic card spacing based on available height | |
| function getCardSpacing() { | |
| const timelineWrapper = document.querySelector('.timeline-wrapper'); | |
| if (!timelineWrapper) return { above: [60, 120, 180], below: [60, 120, 180] }; | |
| const availableHeight = timelineWrapper.clientHeight; | |
| const centerLineOffset = availableHeight / 2; | |
| const usableHeight = Math.max(centerLineOffset - 80, 80); // Leave margin for card height | |
| // Calculate spacing between levels (3 levels each side) | |
| // Use the minimum of: default spacing OR available space divided by 3 | |
| const levelSpacing = Math.max(40, Math.min(60, usableHeight / 3)); // At least 40px, max 60px | |
| return { | |
| above: [ | |
| levelSpacing, | |
| levelSpacing * 2, | |
| levelSpacing * 3 | |
| ], | |
| below: [ | |
| levelSpacing, | |
| levelSpacing * 2, | |
| levelSpacing * 3 | |
| ] | |
| }; | |
| } | |
| // Efficiently update card positions without re-rendering | |
| function updateCardPositions() { | |
| const cardSpacing = getCardSpacing(); | |
| const timeline = document.getElementById('timeline'); | |
| if (!timeline) return; | |
| // Update all timeline items | |
| const items = timeline.querySelectorAll('.timeline-item'); | |
| items.forEach(item => { | |
| const positionClass = item.className.match(/(above|below)-(\\d)/); | |
| if (!positionClass) return; | |
| const isAbove = positionClass[1] === 'above'; | |
| const level = parseInt(positionClass[2]) - 1; // 0, 1, or 2 | |
| // Update connector height | |
| const connector = item.querySelector('.timeline-connector'); | |
| if (connector) { | |
| if (isAbove) { | |
| connector.style.height = cardSpacing.above[level] + 'px'; | |
| } else { | |
| connector.style.height = cardSpacing.below[level] + 'px'; | |
| } | |
| } | |
| // Update label position | |
| const label = item.querySelector('.timeline-label'); | |
| if (label) { | |
| if (isAbove) { | |
| const dynamicBottom = cardSpacing.above[level] + 'px'; | |
| label.style.bottom = dynamicBottom; | |
| label.style.top = ''; | |
| label.dataset.dynamicBottom = dynamicBottom; | |
| } else { | |
| const dynamicTop = cardSpacing.below[level] + 'px'; | |
| label.style.top = dynamicTop; | |
| label.style.bottom = ''; | |
| label.dataset.dynamicTop = dynamicTop; | |
| } | |
| } | |
| }); | |
| } | |
| // Theme management | |
| let currentTheme = 'light'; | |
| // Theme detection and switching functions | |
| function detectSystemTheme() { | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| } | |
| function loadTheme() { | |
| // Check localStorage first, then system preference | |
| const savedTheme = localStorage.getItem('theme'); | |
| const systemTheme = detectSystemTheme(); | |
| currentTheme = savedTheme || systemTheme; | |
| applyTheme(currentTheme); | |
| } | |
| function applyTheme(theme) { | |
| currentTheme = theme; | |
| document.documentElement.setAttribute('data-theme', theme); | |
| // Update theme toggle icon | |
| const themeIcon = document.querySelector('.theme-icon'); | |
| if (themeIcon) { | |
| themeIcon.textContent = theme === 'dark' ? '☀️' : '🌙'; | |
| } | |
| // Save to localStorage | |
| localStorage.setItem('theme', theme); | |
| } | |
| function toggleTheme() { | |
| const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; | |
| applyTheme(newTheme); | |
| } | |
| // Listen for system theme changes | |
| function setupThemeListener() { | |
| const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); | |
| mediaQuery.addEventListener('change', (e) => { | |
| // Only auto-switch if user hasn't manually set a preference | |
| if (!localStorage.getItem('theme')) { | |
| const newTheme = e.matches ? 'dark' : 'light'; | |
| applyTheme(newTheme); | |
| } | |
| }); | |
| } | |
| async function loadTimeline() { | |
| const timeline = document.getElementById('timeline'); | |
| timeline.innerHTML = '<div class="loading">Loading timeline...</div>'; | |
| try { | |
| const startDate = document.getElementById('startDate').value; | |
| const endDate = document.getElementById('endDate').value; | |
| // Get selected modalities | |
| const selectedModalities = Array.from(document.querySelectorAll('.modality-checkbox input:checked')) | |
| .map(checkbox => checkbox.value); | |
| // Get selected tasks | |
| const selectedTasks = Array.from(document.querySelectorAll('.task-checkbox input:checked')) | |
| .map(checkbox => checkbox.value); | |
| let url = '/api/models'; | |
| const params = new URLSearchParams(); | |
| if (startDate) params.append('start_date', startDate); | |
| if (endDate) params.append('end_date', endDate); | |
| selectedModalities.forEach(modality => params.append('modality', modality)); | |
| selectedTasks.forEach(task => params.append('task', task)); | |
| if (params.toString()) url += '?' + params.toString(); | |
| const response = await fetch(url); | |
| const data = await response.json(); | |
| if (!data.success) { | |
| throw new Error(data.error); | |
| } | |
| allModels = data.models; | |
| currentModels = data.models; | |
| // Initialize date inputs to min/max once (based on current data) | |
| if (!dateInitialized && currentModels && currentModels.length > 0) { | |
| const validDates = currentModels | |
| .map(m => m.transformers_date) | |
| .filter(d => !!d) | |
| .sort(); | |
| if (validDates.length > 0) { | |
| const minDate = validDates[0]; | |
| const maxDate = validDates[validDates.length - 1]; | |
| const startEl = document.getElementById('startDate'); | |
| const endEl = document.getElementById('endDate'); | |
| if (startEl) { | |
| startEl.min = minDate; | |
| startEl.max = maxDate; | |
| if (!startEl.value) startEl.value = minDate; | |
| } | |
| if (endEl) { | |
| endEl.min = minDate; | |
| endEl.max = maxDate; | |
| if (!endEl.value) endEl.value = maxDate; | |
| } | |
| dateInitialized = true; | |
| } | |
| } | |
| updateStats(data.total_count, data.filtered_count); | |
| renderTimeline(currentModels); | |
| } catch (error) { | |
| timeline.innerHTML = `<div class="error">Error loading timeline: ${error.message}</div>`; | |
| } | |
| } | |
| function updateStats(totalCount, displayedCount) { | |
| document.getElementById('totalCount').textContent = totalCount; | |
| document.getElementById('displayedCount').textContent = displayedCount; | |
| if (currentModels.length > 0) { | |
| const firstDate = currentModels[0].transformers_date; | |
| const lastDate = currentModels[currentModels.length - 1].transformers_date; | |
| document.getElementById('dateRange').textContent = `${firstDate} — ${lastDate}`; | |
| } else { | |
| document.getElementById('dateRange').textContent = 'No data'; | |
| } | |
| } | |
| function formatDate(dateString) { | |
| if (!dateString || dateString === 'Unknown Date') return 'Unknown Date'; | |
| try { | |
| const date = new Date(dateString); | |
| return date.toLocaleDateString('en-US', { | |
| year: 'numeric', | |
| month: 'short', | |
| day: 'numeric' | |
| }); | |
| } catch (error) { | |
| return dateString; // Return original if parsing fails | |
| } | |
| } | |
| function createFaintColor(hexColor) { | |
| // Remove # if present | |
| hexColor = hexColor.replace('#', ''); | |
| // Parse RGB values | |
| const r = parseInt(hexColor.substr(0, 2), 16); | |
| const g = parseInt(hexColor.substr(2, 2), 16); | |
| const b = parseInt(hexColor.substr(4, 2), 16); | |
| // Reduce saturation by mixing with a lighter gray (keeping brightness) | |
| const lightGray = 200; // Light gray to maintain brightness | |
| const desaturatedR = Math.floor(r * 0.6 + lightGray * 0.4); | |
| const desaturatedG = Math.floor(g * 0.6 + lightGray * 0.4); | |
| const desaturatedB = Math.floor(b * 0.6 + lightGray * 0.4); | |
| // Add some opacity (70%) | |
| return `rgba(${desaturatedR}, ${desaturatedG}, ${desaturatedB}, 0.7)`; | |
| } | |
| function markdownToHtml(markdown) { | |
| if (!markdown) return ''; | |
| // Simple markdown to HTML conversion | |
| let html = markdown | |
| // Links first (before other processing) | |
| .replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank" onclick="event.stopPropagation()">$1</a>') | |
| // Headers (handle # at start of line) | |
| .replace(/^#{3}\\s+(.*$)/gm, '<h3>$1</h3>') | |
| .replace(/^#{2}\\s+(.*$)/gm, '<h2>$1</h2>') | |
| .replace(/^#{1}\\s+(.*$)/gm, '<h1>$1</h1>') | |
| // Bold and italic | |
| .replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, '<strong><em>$1</em></strong>') | |
| .replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>') | |
| .replace(/\\*([^*]+)\\*/g, '<em>$1</em>') | |
| // Code (inline) | |
| .replace(/`([^`]+)`/g, '<code>$1</code>') | |
| // Lists (simple) | |
| .replace(/^-\\s+(.+$)/gm, '<li>$1</li>') | |
| // Split into paragraphs and process | |
| .split('\\n\\n') | |
| .map(paragraph => { | |
| paragraph = paragraph.trim(); | |
| if (!paragraph) return ''; | |
| // Don't wrap headers, lists, or already wrapped content in paragraphs | |
| if (paragraph.match(/^<h[1-6]>|^<li>|^<ul>|^<ol>|^<p>/)) { | |
| return paragraph; | |
| } | |
| // Wrap other content in paragraphs | |
| return '<p>' + paragraph.replace(/\\n/g, '<br>') + '</p>'; | |
| }) | |
| .filter(p => p.length > 0) | |
| .join('') | |
| // Wrap consecutive list items in ul tags | |
| .replace(/(<li>.*?<\\/li>)/gs, '<ul>$1</ul>') | |
| // Clean up multiple consecutive ul tags | |
| .replace(/<\\/ul>\\s*<ul>/g, ''); | |
| return html; | |
| } | |
| function renderTimeline(models, preservePosition = false) { | |
| const timeline = document.getElementById('timeline'); | |
| const timelineScroll = document.getElementById('timelineScroll'); | |
| if (models.length === 0) { | |
| timeline.innerHTML = '<div class="loading">No models found with the current filters</div>'; | |
| return; | |
| } | |
| // Sort models chronologically by transformers_date for proper timeline display | |
| const sortedModels = [...models].sort((a, b) => | |
| new Date(a.transformers_date) - new Date(b.transformers_date) | |
| ); | |
| // Calculate dimensions with zoom level | |
| containerWidth = timelineScroll.clientWidth; | |
| const baseSpacing = 80; // Base spacing between models | |
| const actualSpacing = baseSpacing * zoomLevel; | |
| timelineWidth = Math.max(containerWidth, sortedModels.length * actualSpacing + 200); | |
| timeline.style.width = timelineWidth + 'px'; | |
| // Clear timeline and add centered line | |
| timeline.innerHTML = '<div class="timeline-line"></div>'; | |
| // Add date markers before adding models | |
| addDateMarkers(sortedModels, actualSpacing); | |
| // Get dynamic card spacing based on available height | |
| const cardSpacing = getCardSpacing(); | |
| // Create wave patterns for stacking - simpler pattern | |
| const abovePattern = [1, 2, 3]; // 3 levels above | |
| const belowPattern = [1, 2, 3]; // 3 levels below | |
| let aboveIndex = 0; | |
| let belowIndex = 0; | |
| // Add model items with wave positioning | |
| sortedModels.forEach((model, index) => { | |
| const position = (index * actualSpacing) + 100; // Linear spacing with zoom | |
| const item = document.createElement('div'); | |
| // Alternate between above and below the axis | |
| let positionClass; | |
| if (index % 2 === 0) { | |
| // Above the axis | |
| positionClass = `above-${abovePattern[aboveIndex % abovePattern.length]}`; | |
| aboveIndex++; | |
| } else { | |
| // Below the axis | |
| positionClass = `below-${belowPattern[belowIndex % belowPattern.length]}`; | |
| belowIndex++; | |
| } | |
| item.className = `timeline-item ${positionClass}`; | |
| item.style.left = position + 'px'; | |
| // Apply dynamic positioning based on available height | |
| const levelIndex = parseInt(positionClass.split('-')[1]) - 1; // Get 0, 1, or 2 | |
| const isAbove = positionClass.includes('above'); | |
| // Store the dynamic spacing as data attributes for later use | |
| if (isAbove) { | |
| item.dataset.dynamicBottom = cardSpacing.above[levelIndex] + 'px'; | |
| item.dataset.dynamicHeight = cardSpacing.above[levelIndex] + 'px'; | |
| } else { | |
| item.dataset.dynamicTop = cardSpacing.below[levelIndex] + 'px'; | |
| item.dataset.dynamicHeight = cardSpacing.below[levelIndex] + 'px'; | |
| } | |
| const dot = document.createElement('div'); | |
| dot.className = 'timeline-dot'; | |
| // Use modality color for background and a darker version for border | |
| const modalityColor = model.modality_color || '#8B5CF6'; | |
| const darkenColor = (hex, percent = 30) => { | |
| const num = parseInt(hex.replace('#', ''), 16); | |
| const amt = Math.round(2.55 * percent); | |
| const R = Math.max(0, (num >> 16) - amt); | |
| const G = Math.max(0, (num >> 8 & 0x00FF) - amt); | |
| const B = Math.max(0, (num & 0x0000FF) - amt); | |
| return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1); | |
| }; | |
| dot.style.backgroundColor = modalityColor; | |
| dot.style.borderColor = darkenColor(modalityColor, 10); | |
| const connector = document.createElement('div'); | |
| connector.className = 'timeline-connector'; | |
| // Apply dynamic height to connector | |
| if (isAbove) { | |
| connector.style.bottom = '9px'; | |
| connector.style.height = cardSpacing.above[levelIndex] + 'px'; | |
| } else { | |
| connector.style.top = '9px'; | |
| connector.style.height = cardSpacing.below[levelIndex] + 'px'; | |
| } | |
| const label = document.createElement('div'); | |
| label.className = 'timeline-label'; | |
| label.style.borderLeftColor = model.modality_color || '#8B5CF6'; | |
| // Set the modality color as a CSS custom property for hover effects | |
| label.style.setProperty('--modality-color', model.modality_color || '#8B5CF6'); | |
| // Apply dynamic positioning to label and store for later restoration | |
| if (isAbove) { | |
| const dynamicBottom = cardSpacing.above[levelIndex] + 'px'; | |
| label.style.bottom = dynamicBottom; | |
| label.dataset.dynamicBottom = dynamicBottom; | |
| } else { | |
| const dynamicTop = cardSpacing.below[levelIndex] + 'px'; | |
| label.style.top = dynamicTop; | |
| label.dataset.dynamicTop = dynamicTop; | |
| } | |
| label.dataset.positionClass = positionClass; // Store for collapse restoration | |
| // Ensure the model name is always displayed | |
| const modelName = model.display_name || model.model_name || 'Unknown Model'; | |
| const modelDate = formatDate(model.transformers_date || 'Unknown Date'); | |
| const description = model.description || 'No description available.'; | |
| // Set initial compact content (no tasks, description, or learn more) | |
| label.innerHTML = ` | |
| <div class="model-title">${modelName}</div> | |
| <div class="timeline-date">${modelDate}</div> | |
| `; | |
| // Store expanded content data for later use | |
| label.dataset.modelName = modelName; | |
| label.dataset.modelDate = modelDate; | |
| label.dataset.description = description; | |
| label.dataset.learnMoreUrl = `https://huggingface.co/docs/transformers/main/en/model_doc/${model.model_name}`; | |
| if (model.tasks) { | |
| label.dataset.tasks = JSON.stringify(model.tasks); | |
| } | |
| // Add click handler for expansion | |
| label.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| // Don't toggle if clicking inside an expanded card | |
| if (label.classList.contains('expanded')) { | |
| return; | |
| } | |
| // Close any other expanded cards | |
| document.querySelectorAll('.timeline-label.expanded').forEach(otherLabel => { | |
| if (otherLabel !== label) { | |
| otherLabel.classList.remove('expanded'); | |
| // Restore compact content for other cards | |
| setTimeout(() => { | |
| otherLabel.innerHTML = ` | |
| <div class="model-title">${otherLabel.dataset.modelName}</div> | |
| <div class="timeline-date">${otherLabel.dataset.modelDate}</div> | |
| `; | |
| // Restore dynamic positioning after content change | |
| setTimeout(() => { | |
| // Restore the dynamically calculated position | |
| const isAbove = otherLabel.dataset.positionClass && otherLabel.dataset.positionClass.includes('above'); | |
| if (isAbove && otherLabel.dataset.dynamicBottom) { | |
| otherLabel.style.bottom = otherLabel.dataset.dynamicBottom; | |
| otherLabel.style.top = ''; | |
| } else if (!isAbove && otherLabel.dataset.dynamicTop) { | |
| otherLabel.style.top = otherLabel.dataset.dynamicTop; | |
| otherLabel.style.bottom = ''; | |
| } | |
| otherLabel.style.left = ''; | |
| otherLabel.style.right = ''; | |
| otherLabel.style.transform = 'translateX(-50%)'; | |
| otherLabel.parentElement.style.zIndex = ''; | |
| }, 50); | |
| }, 50); | |
| } | |
| }); | |
| // Check if currently expanded | |
| const isExpanding = !label.classList.contains('expanded'); | |
| if (isExpanding) { | |
| // Calculate current position BEFORE changing content | |
| const rect = label.getBoundingClientRect(); | |
| const containerRect = label.parentElement.getBoundingClientRect(); | |
| const currentTop = rect.top - containerRect.top; | |
| const currentBottom = containerRect.bottom - rect.bottom; | |
| // Determine if this is above or below axis | |
| const isAboveAxis = positionClass.includes('above'); | |
| // Generate expanded content | |
| const storedTasks = label.dataset.tasks ? JSON.parse(label.dataset.tasks) : []; | |
| const formattedDescription = markdownToHtml(label.dataset.description); | |
| // Format tasks for display with task-specific colors | |
| const tasksHtml = storedTasks && storedTasks.length > 0 ? | |
| `<div class="model-tasks"> | |
| <div class="tasks-label">Supported Tasks:</div> | |
| <div class="tasks-list"> | |
| ${storedTasks.map(task => { | |
| const taskColor = getTaskColor(task); | |
| const taskName = getTaskDisplayName(task); | |
| return `<span class="task-badge" style="background-color: ${taskColor}">${taskName}</span>`; | |
| }).join('')} | |
| </div> | |
| </div>` : | |
| '<div class="model-tasks"><div class="tasks-label">No tasks available</div></div>'; | |
| // Set expanded content | |
| label.innerHTML = ` | |
| <div class="model-title">${label.dataset.modelName}</div> | |
| <div class="timeline-date">${label.dataset.modelDate}</div> | |
| ${tasksHtml} | |
| <div class="model-description"> | |
| <div class="description-content">${formattedDescription}</div> | |
| <div class="description-fade"></div> | |
| </div> | |
| <a href="${label.dataset.learnMoreUrl}" | |
| target="_blank" | |
| class="learn-more" | |
| onclick="event.stopPropagation()"> | |
| Learn More → | |
| </a> | |
| `; | |
| // Add expanded class | |
| label.classList.add('expanded'); | |
| // Ensure this timeline item is on top | |
| item.style.zIndex = '10000'; | |
| // Calculate timeline container bounds | |
| const timelineContainer = document.querySelector('.timeline-container'); | |
| const containerBounds = timelineContainer.getBoundingClientRect(); | |
| const timelineItemRect = item.getBoundingClientRect(); | |
| // Get expanded card dimensions (it's now rendered with expanded content) | |
| const expandedRect = label.getBoundingClientRect(); | |
| const cardWidth = 550; // Known width from CSS | |
| const cardHalfWidth = cardWidth / 2; | |
| // Calculate timeline item's center position relative to container | |
| const itemCenterX = timelineItemRect.left + (timelineItemRect.width / 2) - containerBounds.left; | |
| // Calculate default centered position bounds | |
| const defaultLeft = itemCenterX - cardHalfWidth; | |
| const defaultRight = itemCenterX + cardHalfWidth; | |
| // Container padding to ensure cards don't touch edges | |
| const padding = 20; | |
| const containerWidth = containerBounds.width; | |
| // Determine optimal positioning | |
| let finalTransform = 'translateX(-50%)'; // Default centered | |
| let finalLeft = ''; | |
| let finalRight = ''; | |
| if (defaultLeft < padding) { | |
| // Card would extend beyond left edge - align to left with padding | |
| finalTransform = 'translateX(-5%)'; | |
| finalLeft = ''; | |
| } else if (defaultRight > (containerWidth - padding)) { | |
| // Card would extend beyond right edge - align to right with padding | |
| finalTransform = 'translateX(-95%)'; | |
| finalRight = ''; | |
| } | |
| // Apply positioning adjustments | |
| label.style.transform = finalTransform; | |
| // if (finalLeft) label.style.left = finalLeft; | |
| // if (finalRight) label.style.right = finalRight; | |
| // Set vertical positioning to keep the correct edge fixed | |
| if (isAboveAxis) { | |
| // Keep top edge fixed - set top position | |
| label.style.bottom = 'auto'; | |
| label.style.top = currentTop + 'px'; | |
| } else { | |
| // Keep bottom edge fixed - set bottom position | |
| label.style.top = 'auto'; | |
| label.style.bottom = currentBottom + 'px'; | |
| } | |
| } else { | |
| // Contracting - remove expanded class first | |
| label.classList.remove('expanded'); | |
| // Wait for CSS transition to start, then restore compact content | |
| setTimeout(() => { | |
| // Restore compact content | |
| label.innerHTML = ` | |
| <div class="model-title">${label.dataset.modelName}</div> | |
| <div class="timeline-date">${label.dataset.modelDate}</div> | |
| `; | |
| // Restore dynamic positioning after content change | |
| setTimeout(() => { | |
| // Restore the dynamically calculated position | |
| const isAbove = label.dataset.positionClass && label.dataset.positionClass.includes('above'); | |
| if (isAbove && label.dataset.dynamicBottom) { | |
| label.style.bottom = label.dataset.dynamicBottom; | |
| label.style.top = ''; | |
| } else if (!isAbove && label.dataset.dynamicTop) { | |
| label.style.top = label.dataset.dynamicTop; | |
| label.style.bottom = ''; | |
| } | |
| label.style.left = ''; | |
| label.style.right = ''; | |
| // Reset transform: depends on current position | |
| label.style.transform = 'translateX(-50%)'; | |
| item.style.zIndex = ''; | |
| }, 50); | |
| }, 50); | |
| } | |
| }); | |
| const releaseInfo = model.release_date && model.release_date !== 'None' ? | |
| `\\nReleased: ${model.release_date}` : '\\nRelease date: Unknown'; | |
| const modalityInfo = model.modality_name ? `\\nModality: ${model.modality_name}` : ''; | |
| item.title = `${modelName}\\nAdded: ${modelDate}${releaseInfo}${modalityInfo}`; | |
| // Add dot, connector line, and label | |
| item.appendChild(dot); | |
| item.appendChild(connector); | |
| item.appendChild(label); | |
| timeline.appendChild(item); | |
| }); | |
| // Only focus on the end for initial load, not for zoom operations | |
| if (!preservePosition) { | |
| // Initial load - focus on the end (most recent models) | |
| timelineOffset = Math.min(0, containerWidth - timelineWidth); | |
| } | |
| updateTimelinePosition(); | |
| setupNavigation(); | |
| updateZoomIndicator(); | |
| } | |
| function updateZoomIndicator() { | |
| const zoomIndicator = document.getElementById('zoomLevel'); | |
| zoomIndicator.textContent = Math.round(zoomLevel * 100) + '%'; | |
| } | |
| function zoomIn() { | |
| if (zoomLevel < maxZoom) { | |
| // Store current state for smooth transition | |
| const oldTimelineWidth = timelineWidth; | |
| const currentCenterX = -timelineOffset + (containerWidth / 2); | |
| const centerRatio = currentCenterX / oldTimelineWidth; | |
| // Update zoom level | |
| zoomLevel = Math.min(maxZoom, zoomLevel * 1.2); | |
| // Calculate new dimensions | |
| const baseSpacing = 80; | |
| const newActualSpacing = baseSpacing * zoomLevel; | |
| const newTimelineWidth = Math.max(containerWidth, currentModels.length * newActualSpacing + 200); | |
| // Calculate new position to preserve center | |
| const newCenterX = centerRatio * newTimelineWidth; | |
| const targetOffset = -(newCenterX - (containerWidth / 2)); | |
| // Apply smooth transition by setting target position | |
| timelineOffset = targetOffset; | |
| // Re-render with smooth transition | |
| renderTimeline(currentModels, true); | |
| } | |
| } | |
| function zoomOut() { | |
| if (zoomLevel > minZoom) { | |
| // Store current state for smooth transition | |
| const oldTimelineWidth = timelineWidth; | |
| const currentCenterX = -timelineOffset + (containerWidth / 2); | |
| const centerRatio = currentCenterX / oldTimelineWidth; | |
| // Update zoom level | |
| zoomLevel = Math.max(minZoom, zoomLevel / 1.2); | |
| // Calculate new dimensions | |
| const baseSpacing = 80; | |
| const newActualSpacing = baseSpacing * zoomLevel; | |
| const newTimelineWidth = Math.max(containerWidth, currentModels.length * newActualSpacing + 200); | |
| // Calculate new position to preserve center | |
| const newCenterX = centerRatio * newTimelineWidth; | |
| const targetOffset = -(newCenterX - (containerWidth / 2)); | |
| // Apply smooth transition by setting target position | |
| timelineOffset = targetOffset; | |
| // Re-render with smooth transition | |
| renderTimeline(currentModels, true); | |
| } | |
| } | |
| function addDateMarkers(models, spacing) { | |
| if (models.length === 0) return; | |
| const timeline = document.getElementById('timeline'); | |
| // Sort models by date to ensure proper chronological order | |
| const sortedModels = [...models].sort((a, b) => | |
| new Date(a.transformers_date) - new Date(b.transformers_date) | |
| ); | |
| // Get date range | |
| const startDate = new Date(sortedModels[0].transformers_date); | |
| const endDate = new Date(sortedModels[sortedModels.length - 1].transformers_date); | |
| // Determine marker granularity based on spacing and zoom | |
| const totalSpan = spacing * models.length; | |
| const pixelsPerDay = totalSpan / ((endDate - startDate) / (1000 * 60 * 60 * 24)); | |
| let markerType, increment, format; | |
| if (pixelsPerDay > 4) { | |
| // Very zoomed in - show months | |
| markerType = 'month'; | |
| increment = 1; | |
| format = (date) => date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); | |
| } else if (pixelsPerDay > 1.5) { | |
| // Medium zoom - show quarters | |
| markerType = 'quarter'; | |
| increment = 3; | |
| format = (date) => { | |
| const quarter = Math.floor(date.getMonth() / 3) + 1; | |
| return `Q${quarter} ${date.getFullYear()}`; | |
| }; | |
| } else { | |
| // Zoomed out - show years only | |
| markerType = 'year'; | |
| increment = 12; | |
| format = (date) => date.getFullYear().toString(); | |
| } | |
| // Generate markers based on actual model positions | |
| let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1); | |
| // Round to appropriate boundary | |
| if (markerType === 'quarter') { | |
| currentDate.setMonth(Math.floor(currentDate.getMonth() / 3) * 3); | |
| } else if (markerType === 'year') { | |
| currentDate.setMonth(0); | |
| } | |
| while (currentDate <= endDate) { | |
| // Find the boundary position between models before and after this date | |
| let boundaryPosition = null; | |
| for (let i = 0; i < sortedModels.length - 1; i++) { | |
| const currentModelDate = new Date(sortedModels[i].transformers_date); | |
| const nextModelDate = new Date(sortedModels[i + 1].transformers_date); | |
| // Check if the marker date falls between these two models | |
| // The marker should appear where the time period actually starts | |
| if (currentModelDate < currentDate && currentDate <= nextModelDate) { | |
| // Position the marker between these two models | |
| boundaryPosition = (i + 1) * spacing + 68; | |
| break; | |
| } | |
| } | |
| // If no boundary found (e.g., date is before first model or after last model) | |
| if (boundaryPosition === null) { | |
| if (currentDate < new Date(sortedModels[0].transformers_date)) { | |
| // Date is before first model | |
| boundaryPosition = 50; // Position before first model | |
| } else { | |
| // Date is after last model | |
| boundaryPosition = (sortedModels.length - 1) * spacing + 150; // Position after last model | |
| } | |
| } | |
| const position = boundaryPosition; | |
| // Create marker only if it's within visible range and not too close to adjacent markers | |
| // Only check VS the last existing marker, since markers are added more recent each time. | |
| const existingMarkers = timeline.querySelectorAll('.date-marker'); | |
| let shouldRemovePrevious = false; | |
| let lastMarker = null; | |
| let lastPos = null; | |
| if (existingMarkers.length > 0) { | |
| lastMarker = existingMarkers[existingMarkers.length - 1]; | |
| lastPos = parseFloat(lastMarker.style.left); | |
| if (Math.abs(position - lastPos) < 60) { // Minimum spacing between markers | |
| shouldRemovePrevious = true; | |
| } | |
| } | |
| if (shouldRemovePrevious && lastMarker) { | |
| lastMarker.remove(); | |
| // Also remove the label associated with that marker (next sibling in .date-label) | |
| const timelineLabels = timeline.querySelectorAll('.date-label'); | |
| // Find the label with the left matching the previous marker | |
| for (let i = 0; i < timelineLabels.length; i++) { | |
| const lbl = timelineLabels[i]; | |
| // label.style.left is like "123px" | |
| if (Math.abs(parseFloat(lbl.style.left) - (lastPos + 8)) < 2) { // allow for rounding errors | |
| lbl.remove(); | |
| break; | |
| } | |
| } | |
| } | |
| // Create marker | |
| const marker = document.createElement('div'); | |
| marker.className = `date-marker ${markerType}`; | |
| marker.style.left = position + 'px'; | |
| // Create vertical line (dashed) | |
| const line = document.createElement('div'); | |
| line.style.position = 'absolute'; | |
| line.style.top = '0px'; | |
| line.style.bottom = '0px'; | |
| line.style.left = '0px'; | |
| line.style.width = '1px'; | |
| line.style.borderLeft = '1px dashed #9ca3af'; | |
| line.style.opacity = '0.5'; | |
| line.style.zIndex = '0'; | |
| if (markerType === 'year') { | |
| line.style.borderLeft = '1px dashed #6b7280'; | |
| line.style.opacity = '0.7'; | |
| } | |
| marker.appendChild(line); | |
| // Create label (as a separate sibling, not a child) | |
| const label = document.createElement('div'); | |
| label.className = 'date-label'; | |
| label.textContent = format(currentDate); | |
| label.style.left = position + 8 + 'px'; // offset similar to CSS left: 8px for label | |
| timeline.appendChild(marker); | |
| timeline.appendChild(label); | |
| // Move to next marker | |
| currentDate.setMonth(currentDate.getMonth() + increment); | |
| } | |
| } | |
| function updateTimelinePosition() { | |
| const timeline = document.getElementById('timeline'); | |
| timeline.style.transform = `translateX(${timelineOffset}px)`; | |
| // Update navigation buttons | |
| const navLeft = document.getElementById('navLeft'); | |
| const navRight = document.getElementById('navRight'); | |
| navLeft.style.opacity = timelineOffset >= 0 ? '0.3' : '1'; | |
| navRight.style.opacity = timelineOffset <= containerWidth - timelineWidth ? '0.3' : '1'; | |
| } | |
| function setupNavigation() { | |
| const navLeft = document.getElementById('navLeft'); | |
| const navRight = document.getElementById('navRight'); | |
| const timelineWrapper = document.querySelector('.timeline-wrapper'); | |
| const timelineScroll = document.getElementById('timelineScroll'); | |
| const zoomInBtn = document.getElementById('zoomIn'); | |
| const zoomOutBtn = document.getElementById('zoomOut'); | |
| // Arrow navigation | |
| navLeft.onclick = () => { | |
| if (timelineOffset < 0) { | |
| timelineOffset = Math.min(0, timelineOffset + containerWidth * 0.8); | |
| updateTimelinePosition(); | |
| } | |
| }; | |
| navRight.onclick = () => { | |
| const maxOffset = containerWidth - timelineWidth; | |
| if (timelineOffset > maxOffset) { | |
| timelineOffset = Math.max(maxOffset, timelineOffset - containerWidth * 0.8); | |
| updateTimelinePosition(); | |
| } | |
| }; | |
| // Zoom controls | |
| zoomInBtn.onclick = zoomIn; | |
| zoomOutBtn.onclick = zoomOut; | |
| // Drag functionality - works on entire timeline area | |
| timelineWrapper.onmousedown = (e) => { | |
| // Ignore clicks on navigation arrows and zoom controls | |
| if (e.target.closest('.nav-arrow') || e.target.closest('.zoom-controls')) { | |
| return; | |
| } | |
| // Ignore clicks inside expanded cards to allow text selection | |
| if (e.target.closest('.timeline-label.expanded')) { | |
| return; | |
| } | |
| isDragging = true; | |
| startX = e.clientX; | |
| startOffset = timelineOffset; | |
| timelineWrapper.style.cursor = 'grabbing'; | |
| // Remove transition during drag for immediate response | |
| const timeline = document.getElementById('timeline'); | |
| timeline.style.transition = 'none'; | |
| e.preventDefault(); // Prevent text selection | |
| }; | |
| document.onmousemove = (e) => { | |
| if (!isDragging) return; | |
| // Increased sensitivity - 1.3x multiplier for more responsive feel | |
| const deltaX = (e.clientX - startX) * 1.3; | |
| const newOffset = startOffset + deltaX; | |
| const maxOffset = containerWidth - timelineWidth; | |
| timelineOffset = Math.max(maxOffset, Math.min(0, newOffset)); | |
| updateTimelinePosition(); | |
| }; | |
| document.onmouseup = () => { | |
| if (isDragging) { | |
| isDragging = false; | |
| timelineWrapper.style.cursor = 'grab'; | |
| // Restore transition after drag | |
| const timeline = document.getElementById('timeline'); | |
| timeline.style.transition = 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)'; | |
| } | |
| }; | |
| // Touch support for mobile - enhanced responsiveness | |
| timelineWrapper.ontouchstart = (e) => { | |
| // Ignore touches on navigation arrows and zoom controls | |
| if (e.target.closest('.nav-arrow') || e.target.closest('.zoom-controls')) { | |
| return; | |
| } | |
| // Ignore touches inside expanded cards to allow text selection | |
| if (e.target.closest('.timeline-label.expanded')) { | |
| return; | |
| } | |
| isDragging = true; | |
| startX = e.touches[0].clientX; | |
| startOffset = timelineOffset; | |
| // Remove transition during touch drag | |
| const timeline = document.getElementById('timeline'); | |
| timeline.style.transition = 'none'; | |
| }; | |
| timelineWrapper.ontouchmove = (e) => { | |
| if (!isDragging) return; | |
| e.preventDefault(); | |
| // Increased sensitivity for touch as well | |
| const deltaX = (e.touches[0].clientX - startX) * 1.3; | |
| const newOffset = startOffset + deltaX; | |
| const maxOffset = containerWidth - timelineWidth; | |
| timelineOffset = Math.max(maxOffset, Math.min(0, newOffset)); | |
| updateTimelinePosition(); | |
| }; | |
| timelineWrapper.ontouchend = () => { | |
| if (isDragging) { | |
| isDragging = false; | |
| // Restore transition after touch drag | |
| const timeline = document.getElementById('timeline'); | |
| timeline.style.transition = 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)'; | |
| } | |
| }; | |
| // Keyboard navigation and zoom | |
| document.onkeydown = (e) => { | |
| if (e.key === 'ArrowLeft') { | |
| navLeft.onclick(); | |
| } else if (e.key === 'ArrowRight') { | |
| navRight.onclick(); | |
| } else if (e.key === '+' || e.key === '=') { | |
| zoomIn(); | |
| } else if (e.key === '-' || e.key === '_') { | |
| zoomOut(); | |
| } | |
| }; | |
| // Mouse wheel zoom - works anywhere in timeline area | |
| timelineWrapper.onwheel = (e) => { | |
| if (e.ctrlKey || e.metaKey) { | |
| e.preventDefault(); | |
| if (e.deltaY < 0) { | |
| zoomIn(); | |
| } else { | |
| zoomOut(); | |
| } | |
| } | |
| }; | |
| } | |
| function checkAllModalities() { | |
| document.querySelectorAll('.modality-checkbox input').forEach(checkbox => { | |
| checkbox.checked = true; | |
| checkbox.parentElement.classList.add('checked'); | |
| }); | |
| // Auto-refresh timeline | |
| loadTimeline(); | |
| } | |
| function clearAllModalities() { | |
| document.querySelectorAll('.modality-checkbox input').forEach(checkbox => { | |
| checkbox.checked = false; | |
| checkbox.parentElement.classList.remove('checked'); | |
| }); | |
| // Auto-refresh timeline | |
| loadTimeline(); | |
| } | |
| // Task filtering functions | |
| async function loadTasks() { | |
| try { | |
| const response = await fetch('/api/tasks'); | |
| const data = await response.json(); | |
| if (!data.success) { | |
| console.error('Failed to load tasks:', data.error); | |
| return; | |
| } | |
| const taskFilters = document.getElementById('taskFilters'); | |
| taskFilters.innerHTML = ''; | |
| // Store task data for easy lookup | |
| data.tasks.forEach(task => { | |
| taskData[task.key] = { name: task.name, color: task.color }; | |
| }); | |
| data.tasks.forEach(task => { | |
| const checkboxContainer = document.createElement('div'); | |
| checkboxContainer.className = 'task-checkbox'; | |
| checkboxContainer.style.color = task.color; | |
| checkboxContainer.style.setProperty('--task-color', task.color); | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.value = task.key; | |
| checkbox.id = `task-${task.key}`; | |
| checkbox.checked = false; // Start with all tasks unchecked | |
| checkbox.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| if (checkbox.checked) { | |
| checkboxContainer.classList.add('checked'); | |
| } else { | |
| checkboxContainer.classList.remove('checked'); | |
| } | |
| // Auto-refresh timeline | |
| loadTimeline(); | |
| }); | |
| const label = document.createElement('label'); | |
| label.htmlFor = `task-${task.key}`; | |
| label.textContent = task.name; | |
| checkboxContainer.appendChild(checkbox); | |
| checkboxContainer.appendChild(label); | |
| // Add click handler with auto-refresh | |
| checkboxContainer.addEventListener('click', (e) => { | |
| if (e.target.type !== 'checkbox') { | |
| checkbox.checked = !checkbox.checked; | |
| } | |
| checkboxContainer.classList.toggle('checked', checkbox.checked); | |
| // Auto-refresh timeline when task filter changes | |
| loadTimeline(); | |
| }); | |
| taskFilters.appendChild(checkboxContainer); | |
| }); | |
| console.log('✅ Loaded', data.tasks.length, 'task filters'); | |
| } catch (error) { | |
| console.error('Error loading tasks:', error); | |
| } | |
| } | |
| function checkAllTasks() { | |
| document.querySelectorAll('.task-checkbox input').forEach(checkbox => { | |
| checkbox.checked = true; | |
| checkbox.parentElement.classList.add('checked'); | |
| }); | |
| // Auto-refresh timeline | |
| loadTimeline(); | |
| } | |
| function clearAllTasks() { | |
| document.querySelectorAll('.task-checkbox input').forEach(checkbox => { | |
| checkbox.checked = false; | |
| checkbox.parentElement.classList.remove('checked'); | |
| }); | |
| // Auto-refresh timeline | |
| loadTimeline(); | |
| } | |
| // Optional function to clear just date filters if needed | |
| function clearDateFilters() { | |
| document.getElementById('startDate').value = ''; | |
| document.getElementById('endDate').value = ''; | |
| // Auto-refresh will be triggered by the date change events | |
| loadTimeline(); | |
| } | |
| // Function to get task color matching the filter colors | |
| function getTaskColor(taskKey) { | |
| const taskColors = { | |
| "text-generation": "#6366f1", | |
| "text-classification": "#8b5cf6", | |
| "token-classification": "#a855f7", | |
| "question-answering": "#c084fc", | |
| "fill-mask": "#d8b4fe", | |
| "text2text-generation": "#e879f9", | |
| "multiple-choice": "#c026d3", | |
| "image-classification": "#06b6d4", | |
| "object-detection": "#0891b2", | |
| "image-segmentation": "#0e7490", | |
| "semantic-segmentation": "#155e75", | |
| "instance-segmentation": "#164e63", | |
| "universal-segmentation": "#1e40af", | |
| "depth-estimation": "#1d4ed8", | |
| "masked-image-modeling": "#7c3aed", | |
| "causal-image-modeling": "#6d28d9", | |
| "keypoint-detection": "#4338ca", | |
| "keypoint-matching": "#3730a3", | |
| "zero-shot-image-classification": "#2563eb", | |
| "zero-shot-object-detection": "#3b82f6", | |
| "image-to-image": "#60a5fa", | |
| "mask-generation": "#93c5fd", | |
| "image-to-text": "#10b981", | |
| "image-text-to-text": "#059669", | |
| "visual-question-answering": "#047857", | |
| "document-question-answering": "#065f46", | |
| "table-question-answering": "#064e3b", | |
| "video-classification": "#dc2626", | |
| "audio-classification": "#ea580c", | |
| "audio-frame-classification": "#fb923c", | |
| "audio-xvector": "#fdba74", | |
| "automatic-speech-recognition": "#e11d48", | |
| "connectionist-temporal-classification": "#be123c", | |
| "text-to-audio": "#f97316", | |
| "text-to-waveform": "#fb923c", | |
| "time-series-classification": "#84cc16", | |
| "time-series-regression": "#65a30d", | |
| "time-series-prediction": "#4d7c0f" | |
| }; | |
| return taskColors[taskKey] || "#6b7280"; // Default gray for unmapped tasks | |
| } | |
| // Function to get task display name from loaded task data | |
| function getTaskDisplayName(taskKey) { | |
| // Use stored task data if available | |
| if (taskData[taskKey]) { | |
| return taskData[taskKey].name; | |
| } | |
| // Fallback to formatted key if not found | |
| return taskKey.replace(/-/g, ' ').replace(/\b\\w/g, l => l.toUpperCase()); | |
| } | |
| async function loadModalities() { | |
| try { | |
| const response = await fetch('/api/modalities'); | |
| const data = await response.json(); | |
| if (!data.success) { | |
| console.error('Failed to load modalities:', data.error); | |
| return; | |
| } | |
| const modalityFilters = document.getElementById('modalityFilters'); | |
| modalityFilters.innerHTML = ''; | |
| data.modalities.forEach(modality => { | |
| const checkboxContainer = document.createElement('div'); | |
| checkboxContainer.className = 'modality-checkbox'; | |
| checkboxContainer.style.color = modality.color; | |
| checkboxContainer.style.setProperty('--modality-color', modality.color); | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.value = modality.key; | |
| checkbox.id = `modality-${modality.key}`; | |
| checkbox.checked = false; // All modalities unchecked by default | |
| const label = document.createElement('label'); | |
| label.htmlFor = `modality-${modality.key}`; | |
| label.textContent = modality.name; | |
| checkboxContainer.appendChild(checkbox); | |
| checkboxContainer.appendChild(label); | |
| // Add click handler with auto-refresh | |
| checkboxContainer.addEventListener('click', (e) => { | |
| if (e.target.type !== 'checkbox') { | |
| checkbox.checked = !checkbox.checked; | |
| } | |
| checkboxContainer.classList.toggle('checked', checkbox.checked); | |
| // Auto-refresh timeline when modality filter changes | |
| loadTimeline(); | |
| }); | |
| // Set initial state (unchecked, so no 'checked' class) | |
| modalityFilters.appendChild(checkboxContainer); | |
| }); | |
| } catch (error) { | |
| console.error('Error loading modalities:', error); | |
| } | |
| } | |
| // Window resize handler - use efficient position update | |
| let resizeTimeout; | |
| window.addEventListener('resize', () => { | |
| // Update immediately for responsiveness | |
| updateCardPositions(); | |
| // Debounce full re-render for final adjustment | |
| clearTimeout(resizeTimeout); | |
| resizeTimeout = setTimeout(() => { | |
| if (currentModels.length > 0) { | |
| renderTimeline(currentModels, true); | |
| } | |
| }, 250); | |
| }); | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| // Initialize theme | |
| loadTheme(); | |
| setupThemeListener(); | |
| // Add theme toggle event listener | |
| const themeToggle = document.getElementById('themeToggle'); | |
| if (themeToggle) { | |
| themeToggle.addEventListener('click', toggleTheme); | |
| } | |
| await loadModalities(); | |
| await loadTasks(); | |
| loadTimeline(); | |
| // Add auto-refresh for date inputs | |
| document.getElementById('startDate').addEventListener('change', loadTimeline); | |
| document.getElementById('endDate').addEventListener('change', loadTimeline); | |
| // Close expanded cards when clicking outside | |
| document.addEventListener('click', (e) => { | |
| // Don't close if clicking inside an expanded card | |
| if (e.target.closest('.timeline-label.expanded')) { | |
| return; | |
| } | |
| // Don't close if clicking on a timeline label (non-expanded) | |
| if (e.target.closest('.timeline-label:not(.expanded)')) { | |
| return; | |
| } | |
| // Close all expanded cards | |
| document.querySelectorAll('.timeline-label.expanded').forEach(label => { | |
| label.classList.remove('expanded'); | |
| // Restore compact content | |
| setTimeout(() => { | |
| label.innerHTML = ` | |
| <div class="model-title">${label.dataset.modelName}</div> | |
| <div class="timeline-date">${label.dataset.modelDate}</div> | |
| `; | |
| // Restore dynamic positioning after content change | |
| setTimeout(() => { | |
| // Restore the dynamically calculated position | |
| const isAbove = label.dataset.positionClass && label.dataset.positionClass.includes('above'); | |
| if (isAbove && label.dataset.dynamicBottom) { | |
| label.style.bottom = label.dataset.dynamicBottom; | |
| label.style.top = ''; | |
| } else if (!isAbove && label.dataset.dynamicTop) { | |
| label.style.top = label.dataset.dynamicTop; | |
| label.style.bottom = ''; | |
| } | |
| label.style.transform = 'translateX(-50%)'; | |
| label.parentElement.style.zIndex = ''; | |
| }, 50); | |
| }, 50); | |
| }); | |
| }); | |
| // Initialize filters collapsible panel | |
| initFiltersCollapsible(); | |
| }); | |
| function initFiltersCollapsible() { | |
| const panel = document.getElementById('filtersPanel'); | |
| const btn = document.getElementById('toggleFilters'); | |
| if (!panel || !btn) return; | |
| // Ensure panel starts collapsed by default | |
| panel.classList.add('collapsed'); | |
| panel.style.maxHeight = '74px'; | |
| btn.textContent = '▸'; | |
| // Restore persisted state after a short delay to ensure DOM is ready | |
| setTimeout(() => { | |
| const saved = localStorage.getItem('filtersCollapsed'); | |
| if (saved === 'false') { | |
| // Expand to full content height | |
| panel.classList.remove('collapsed'); | |
| panel.style.maxHeight = panel.scrollHeight + 'px'; | |
| btn.textContent = '▾'; | |
| } | |
| // If saved is 'true' or null, keep collapsed (default state) | |
| }, 50); | |
| } | |
| function toggleFilters() { | |
| const panel = document.getElementById('filtersPanel'); | |
| const btn = document.getElementById('toggleFilters'); | |
| if (!panel || !btn) return; | |
| const isCollapsed = panel.classList.contains('collapsed'); | |
| if (isCollapsed) { | |
| // expand to full content height | |
| panel.classList.remove('collapsed'); | |
| panel.style.maxHeight = panel.scrollHeight + 'px'; | |
| btn.textContent = '▾'; | |
| localStorage.setItem('filtersCollapsed', 'false'); | |
| } else { | |
| // collapse to partial height (show a hint of content) | |
| panel.style.maxHeight = panel.scrollHeight + 'px'; // set current height | |
| void panel.offsetHeight; // reflow | |
| panel.classList.add('collapsed'); | |
| const partial = 74; // pixels to show when collapsed | |
| panel.style.maxHeight = partial + 'px'; | |
| btn.textContent = '▸'; | |
| localStorage.setItem('filtersCollapsed', 'true'); | |
| } | |
| // Update card positions immediately | |
| updateCardPositions(); | |
| // Continue updating during animation for smooth transition | |
| const startTime = performance.now(); | |
| const animationDuration = 250; // Match CSS transition time | |
| const animatePositions = (currentTime) => { | |
| const elapsed = currentTime - startTime; | |
| if (elapsed < animationDuration) { | |
| updateCardPositions(); | |
| requestAnimationFrame(animatePositions); | |
| } else { | |
| // Final update after animation completes | |
| updateCardPositions(); | |
| } | |
| }; | |
| requestAnimationFrame(animatePositions); | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| with open(os.path.join(template_dir, "timeline.html"), "w", encoding="utf-8") as f: | |
| f.write(html_content) | |
| def open_browser(): | |
| """Open the browser after a short delay.""" | |
| time.sleep(1.5) | |
| webbrowser.open("http://localhost:5000") | |
| def main(): | |
| """Main function to run the timeline app.""" | |
| print("🤗 Transformers Models Timeline") | |
| print("=" * 50) | |
| # Create templates | |
| create_timeline_template() | |
| # Check if docs directory exists | |
| if not os.path.exists(docs_dir): | |
| print(f"❌ Error: Documentation directory not found at {docs_dir}") | |
| print("Please update the 'docs_dir' variable in the script.") | |
| return | |
| # Parse models to check if any are found | |
| models = parser.parse_all_model_dates() | |
| if not models: | |
| print(f"⚠️ Warning: No models found with release dates in {docs_dir}") | |
| else: | |
| print(f"✅ Found {len(models)} models with release dates") | |
| # Run Flask app | |
| try: | |
| app.run(host="0.0.0.0", port=7860, debug=False) | |
| except KeyboardInterrupt: | |
| print("\n👋 Timeline server stopped") | |
| if __name__ == "__main__": | |
| main() | |