| |
| """ |
| 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 |
| except Exception: |
| 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 = {} |
|
|
| |
| |
| transformers_src = os.path.join(os.path.dirname(docs_dir), "..", "..", "..", "src") |
| if transformers_src not in sys.path: |
| sys.path.insert(0, transformers_src) |
| |
| 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. |
| """ |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| nested = sec.get("sections") or [] |
| for sub in nested: |
| if not isinstance(sub, dict): |
| continue |
| |
| if "local" in sub: |
| local = sub.get("local") |
| if isinstance(local, str) and local.startswith("model_doc/"): |
| result.append(local.split("/", 1)[1]) |
| |
| 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") |
|
|
| |
| if not any([text_models, vision_models, audio_models, video_models, multimodal_models]): |
| return None |
|
|
| |
| 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"]} |
| |
| 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() |
|
|
| |
| model_name = os.path.basename(file_path).replace(".md", "") |
|
|
| |
| release_date = None |
| transformers_date = None |
|
|
| |
| 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) |
|
|
| |
| try: |
| datetime.strptime(transformers_date, "%Y-%m-%d") |
| except ValueError: |
| return None |
|
|
| |
| if release_date.lower() == "none": |
| release_date = None |
| else: |
| |
| try: |
| datetime.strptime(release_date, "%Y-%m-%d") |
| except ValueError: |
| |
| pass |
| else: |
| |
| base = os.path.basename(file_path) |
| if base != "auto.md": |
| print(f"⚠️ Warning: No release/addition dates found in {file_path}; skipping.") |
| return None |
|
|
| |
| modality = self.get_model_modality(model_name) |
|
|
| |
| description = self.extract_model_description(content) |
|
|
| |
| 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: |
| |
| content_no_tags = re.sub(r"<[^>]+>", "", content) |
|
|
| |
| |
| lines = content_no_tags.split("\n") |
| description_start = 0 |
|
|
| |
| 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 |
|
|
| |
| description_lines = lines[description_start:] |
| description = "\n".join(description_lines).strip() |
|
|
| if len(description) > 1000: |
| description = description[:1000] |
| |
| last_space = description.rfind(" ") |
| if last_space > 800: |
| 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: |
| modeling_auto = __import__( |
| "transformers.models.auto.modeling_auto", |
| fromlist=["MODEL_FOR_CAUSAL_LM_MAPPING_NAMES"], |
| ) |
| except ImportError as e: |
| print(f"⚠️ Cannot import transformers.models.auto.modeling_auto: {e}") |
| |
| return self._load_model_task_mappings_from_json() |
|
|
| try: |
| |
| _TASK_MAPPING_ATTRS = { |
| "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", |
| } |
|
|
| task_mappings = {} |
| for task_name, attr_name in _TASK_MAPPING_ATTRS.items(): |
| mapping = getattr(modeling_auto, attr_name, None) |
| if mapping is not None and hasattr(mapping, "keys"): |
| task_mappings[task_name] = mapping |
|
|
| |
| 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 self._load_model_task_mappings_from_json() |
|
|
| def _load_model_task_mappings_from_json(self) -> dict[str, list[str]]: |
| """Load model-to-task mappings from bundled JSON (fallback when import fails).""" |
| import json |
|
|
| json_path = os.path.join(os.path.dirname(__file__), "model_task_mappings.json") |
| if not os.path.isfile(json_path): |
| return {} |
|
|
| try: |
| with open(json_path, "r", encoding="utf-8") as f: |
| model_to_tasks = json.load(f) |
| self.tasks_cache = model_to_tasks |
| print(f"✅ Loaded task mappings from fallback JSON ({len(model_to_tasks)} models)") |
| return model_to_tasks |
| except Exception as e: |
| print(f"❌ Error loading task mappings from JSON: {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() |
|
|
| |
| normalized_name = model_name.lower().replace("_", "-") |
|
|
| |
| if normalized_name in self.tasks_cache: |
| return self.tasks_cache[normalized_name] |
|
|
| |
| 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 |
|
|
|
|
| |
| app = Flask(__name__) |
| |
|
|
| transformers_path = os.path.dirname(transformers.__file__) |
| |
| 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) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| import importlib |
|
|
| _repo_src = os.path.join(repo_dir, "src") |
| if _repo_src not in sys.path: |
| sys.path.insert(0, _repo_src) |
|
|
| _stale_keys = [k for k in sys.modules if k == "transformers" or k.startswith("transformers.")] |
| for _k in _stale_keys: |
| del sys.modules[_k] |
|
|
| importlib.invalidate_caches() |
| import transformers |
|
|
| transformers_path = os.path.dirname(transformers.__file__) |
| else: |
| |
| 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) |
|
|
|
|
| @app.route("/") |
| def index(): |
| """Main timeline page.""" |
| return render_template("timeline.html") |
|
|
|
|
| @app.route("/api/models") |
| 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") |
| tasks = request.args.getlist("task") |
|
|
| try: |
| models = parser.parse_all_model_dates() |
|
|
| |
| if modalities: |
| models = [model for model in models if model["modality"] in modalities] |
|
|
| |
| if tasks: |
| |
| models = [model for model in models if any(task in model.get("tasks", []) for task in tasks)] |
|
|
| |
| 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 |
|
|
|
|
| @app.route("/api/modalities") |
| 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 |
|
|
|
|
| @app.route("/api/tasks") |
| def get_tasks(): |
| """API endpoint to get available tasks/pipelines.""" |
| try: |
| |
| parser.load_model_task_mappings() |
|
|
| |
| all_tasks = set() |
| for model_tasks in parser.tasks_cache.values(): |
| all_tasks.update(model_tasks) |
|
|
| |
| 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"}, |
| } |
|
|
| |
| 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: |
| |
| available_tasks.append( |
| { |
| "key": task, |
| "name": task.replace("-", " ").title(), |
| "color": "#6b7280", |
| } |
| ) |
|
|
| 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_timeline_template() |
|
|
| |
| 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 |
|
|
| |
| 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") |
|
|
| |
| try: |
| app.run(host="0.0.0.0", port=7860, debug=False) |
| except KeyboardInterrupt: |
| print("\n👋 Timeline server stopped") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|