Molbap HF Staff commited on
Commit
49600c8
Β·
1 Parent(s): 69e2272
Files changed (2) hide show
  1. app.py +50 -13
  2. modular_graph_and_candidates.py +529 -0
app.py CHANGED
@@ -13,7 +13,7 @@ from pathlib import Path
13
  import gradio as gr
14
 
15
  # β€”β€” refactored helpers β€”β€”
16
- from modular_graph_and_candidates import build_graph_json, generate_html
17
 
18
  HF_MAIN_REPO = "https://github.com/huggingface/transformers"
19
 
@@ -51,8 +51,8 @@ def _escape_srcdoc(text: str) -> str:
51
  )
52
 
53
 
54
- def run(repo_url: str, threshold: float, multimodal: bool, sim_method: str):
55
- # Always download repo for now - let build_graph_json decide if it needs it
56
  repo_path = clone_or_cache(repo_url)
57
 
58
  graph = build_graph_json(
@@ -73,26 +73,63 @@ def run(repo_url: str, threshold: float, multimodal: bool, sim_method: str):
73
  tmp_json.write_text(json.dumps(graph), encoding="utf-8")
74
  return iframe_html, str(tmp_json)
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ───────────────────────────── UI ────────────────────────────────────────────────
77
 
78
  CUSTOM_CSS = """
79
- #graph_html iframe {height:85vh !important; width:100% !important; border:none;}
80
  """
81
 
82
  with gr.Blocks(css=CUSTOM_CSS) as demo:
83
  gr.Markdown("## πŸ” Modular‑candidate explorer for πŸ€— Transformers")
84
 
85
- with gr.Row():
86
- repo_in = gr.Text(value=HF_MAIN_REPO, label="Repo / fork URL")
87
- thresh = gr.Slider(0.50, 0.95, value=0.5, step=0.01, label="Similarity β‰₯")
88
- multi_cb = gr.Checkbox(label="Only multimodal models")
89
- sim_radio = gr.Radio(["jaccard", "embedding"], value="jaccard", label="Similarity metric")
90
- go_btn = gr.Button("Build graph")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
- html_out = gr.HTML(elem_id="graph_html", show_label=False)
93
- json_out = gr.File(label="Download graph.json")
94
 
95
- go_btn.click(run, [repo_in, thresh, multi_cb, sim_radio], [html_out, json_out])
96
 
97
  if __name__ == "__main__":
98
  demo.launch(allowed_paths=["static"])
 
13
  import gradio as gr
14
 
15
  # β€”β€” refactored helpers β€”β€”
16
+ from modular_graph_and_candidates import build_graph_json, generate_html, build_timeline_json, generate_timeline_html
17
 
18
  HF_MAIN_REPO = "https://github.com/huggingface/transformers"
19
 
 
51
  )
52
 
53
 
54
+ def run_graph(repo_url: str, threshold: float, multimodal: bool, sim_method: str):
55
+ """Generate the dependency graph visualization."""
56
  repo_path = clone_or_cache(repo_url)
57
 
58
  graph = build_graph_json(
 
73
  tmp_json.write_text(json.dumps(graph), encoding="utf-8")
74
  return iframe_html, str(tmp_json)
75
 
76
+ def run_timeline(repo_url: str, threshold: float, multimodal: bool, sim_method: str):
77
+ """Generate the chronological timeline visualization."""
78
+ repo_path = clone_or_cache(repo_url)
79
+
80
+ timeline = build_timeline_json(
81
+ transformers_dir=repo_path,
82
+ threshold=threshold,
83
+ multimodal=multimodal,
84
+ sim_method=sim_method,
85
+ )
86
+
87
+ raw_html = generate_timeline_html(timeline)
88
+
89
+ iframe_html = (
90
+ f'<iframe style="width:100%;height:85vh;border:none;" '
91
+ f'srcdoc="{_escape_srcdoc(raw_html)}"></iframe>'
92
+ )
93
+
94
+ tmp_json = Path(tempfile.mktemp(suffix="_timeline.json"))
95
+ tmp_json.write_text(json.dumps(timeline), encoding="utf-8")
96
+ return iframe_html, str(tmp_json)
97
+
98
  # ───────────────────────────── UI ────────────────────────────────────────────────
99
 
100
  CUSTOM_CSS = """
101
+ #graph_html iframe, #timeline_html iframe {height:85vh !important; width:100% !important; border:none;}
102
  """
103
 
104
  with gr.Blocks(css=CUSTOM_CSS) as demo:
105
  gr.Markdown("## πŸ” Modular‑candidate explorer for πŸ€— Transformers")
106
 
107
+ with gr.Tabs():
108
+ with gr.Tab("Dependency Graph"):
109
+ with gr.Row():
110
+ repo_in = gr.Text(value=HF_MAIN_REPO, label="Repo / fork URL")
111
+ thresh = gr.Slider(0.50, 0.95, value=0.5, step=0.01, label="Similarity β‰₯")
112
+ multi_cb = gr.Checkbox(label="Only multimodal models")
113
+ sim_radio = gr.Radio(["jaccard", "embedding"], value="jaccard", label="Similarity metric")
114
+ go_btn = gr.Button("Build graph")
115
+
116
+ graph_html_out = gr.HTML(elem_id="graph_html", show_label=False)
117
+ graph_json_out = gr.File(label="Download graph.json")
118
+
119
+ go_btn.click(run_graph, [repo_in, thresh, multi_cb, sim_radio], [graph_html_out, graph_json_out])
120
+
121
+ with gr.Tab("Chronological Timeline"):
122
+ with gr.Row():
123
+ timeline_repo_in = gr.Text(value=HF_MAIN_REPO, label="Repo / fork URL")
124
+ timeline_thresh = gr.Slider(0.50, 0.95, value=0.5, step=0.01, label="Similarity β‰₯")
125
+ timeline_multi_cb = gr.Checkbox(label="Only multimodal models")
126
+ timeline_sim_radio = gr.Radio(["jaccard", "embedding"], value="jaccard", label="Similarity metric")
127
+ timeline_btn = gr.Button("Build timeline")
128
 
129
+ timeline_html_out = gr.HTML(elem_id="timeline_html", show_label=False)
130
+ timeline_json_out = gr.File(label="Download timeline.json")
131
 
132
+ timeline_btn.click(run_timeline, [timeline_repo_in, timeline_thresh, timeline_multi_cb, timeline_sim_radio], [timeline_html_out, timeline_json_out])
133
 
134
  if __name__ == "__main__":
135
  demo.launch(allowed_paths=["static"])
modular_graph_and_candidates.py CHANGED
@@ -32,6 +32,7 @@ import argparse
32
  import ast
33
  import json
34
  import re
 
35
  import tokenize
36
  from collections import Counter, defaultdict
37
  from itertools import combinations
@@ -42,6 +43,7 @@ from tqdm import tqdm
42
  import numpy as np
43
  import spaces
44
  import torch
 
45
 
46
  # ────────────────────────────────────────────────────────────────────────────────
47
  # CONFIG
@@ -454,11 +456,163 @@ def build_graph_json(
454
  return graph
455
 
456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  def generate_html(graph: dict) -> str:
458
  """Return the full HTML string with inlined CSS/JS + graph JSON."""
459
  js = JS.replace("__GRAPH_DATA__", json.dumps(graph, separators=(",", ":")))
460
  return HTML.replace("__CSS__", CSS).replace("__JS__", js)
461
 
 
 
 
 
 
462
 
463
 
464
  # ────────────────────────────────────────────────────────────────────────────────
@@ -614,6 +768,381 @@ HTML = """
614
  <script>__JS__</script></body></html>
615
  """
616
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  # ────────────────────────────────────────────────────────────────────────────────
618
  # HTML writer
619
  # ────────────────────────────────────────────────────────────────────────────────
 
32
  import ast
33
  import json
34
  import re
35
+ import subprocess
36
  import tokenize
37
  from collections import Counter, defaultdict
38
  from itertools import combinations
 
43
  import numpy as np
44
  import spaces
45
  import torch
46
+ from datetime import datetime
47
 
48
  # ────────────────────────────────────────────────────────────────────────────────
49
  # CONFIG
 
456
  return graph
457
 
458
 
459
+ # ────────────────────────────────────────────────────────────────────────────────
460
+ # Timeline functions for chronological visualization
461
+ # ────────────────────────────────────────────────────────────────────────────────
462
+
463
+
464
+ def get_model_creation_dates(transformers_dir: Path) -> Dict[str, datetime]:
465
+ """Get creation dates for all model directories by finding the earliest add of the directory path."""
466
+ models_root = transformers_dir / "src/transformers/models"
467
+ creation_dates: Dict[str, datetime] = {}
468
+
469
+ if not models_root.exists():
470
+ return creation_dates
471
+
472
+ def run_git(args: list[str]) -> subprocess.CompletedProcess:
473
+ return subprocess.run(
474
+ ["git"] + args,
475
+ cwd=transformers_dir,
476
+ capture_output=True,
477
+ text=True,
478
+ timeout=120,
479
+ )
480
+
481
+ # Ensure full history; shallow clones make every path look newly added "today".
482
+ shallow = run_git(["rev-parse", "--is-shallow-repository"])
483
+ if shallow.returncode == 0 and shallow.stdout.strip() == "true":
484
+ # Try best-effort unshallow; if it fails, we still proceed.
485
+ run_git(["fetch", "--unshallow", "--tags", "--prune"]) # ignore return code
486
+ # Fallback if server forbids --unshallow
487
+ run_git(["fetch", "--depth=100000", "--tags", "--prune"])
488
+
489
+ for model_dir in models_root.iterdir():
490
+ if not model_dir.is_dir():
491
+ continue
492
+
493
+ rel = f"src/transformers/models/{model_dir.name}/"
494
+
495
+ # Earliest commit that ADDED something under this directory.
496
+ # Use a stable delimiter to avoid locale/spacing issues.
497
+ proc = run_git([
498
+ "log",
499
+ "--reverse", # oldest β†’ newest
500
+ "--diff-filter=A", # additions only
501
+ "--date=short", # YYYY-MM-DD
502
+ '--format=%H|%ad', # hash|date
503
+ "--",
504
+ rel,
505
+ ])
506
+
507
+ if proc.returncode != 0 or not proc.stdout.strip():
508
+ # As a fallback, look at the earliest commit touching any tracked file under the dir.
509
+ # This can catch cases where files were moved (rename) rather than added.
510
+ ls = run_git(["ls-files", rel])
511
+ files = [ln for ln in ls.stdout.splitlines() if ln.strip()]
512
+ best_date: datetime | None = None
513
+ if files:
514
+ for fp in files:
515
+ proc_file = run_git([
516
+ "log",
517
+ "--reverse",
518
+ "--diff-filter=A",
519
+ "--date=short",
520
+ "--format=%H|%ad",
521
+ "--",
522
+ fp,
523
+ ])
524
+ line = proc_file.stdout.splitlines()[0].strip() if proc_file.stdout else ""
525
+ if line and "|" in line:
526
+ _, d = line.split("|", 1)
527
+ try:
528
+ dt = datetime.strptime(d.strip(), "%Y-%m-%d")
529
+ if best_date is None or dt < best_date:
530
+ best_date = dt
531
+ except ValueError:
532
+ pass
533
+ if best_date is not None:
534
+ creation_dates[model_dir.name] = best_date
535
+ print(f"βœ… {model_dir.name}: {best_date.strftime('%Y-%m-%d')}")
536
+ else:
537
+ print(f"❌ {model_dir.name}: no add commit found")
538
+ continue
539
+
540
+ first_line = proc.stdout.splitlines()[0].strip() # oldest add
541
+ if "|" in first_line:
542
+ _, date_str = first_line.split("|", 1)
543
+ try:
544
+ creation_dates[model_dir.name] = datetime.strptime(date_str.strip(), "%Y-%m-%d")
545
+ print(f"βœ… {model_dir.name}: {date_str.strip()}")
546
+ except ValueError:
547
+ print(f"❌ {model_dir.name}: bad date format: {date_str!r}")
548
+ else:
549
+ print(f"❌ {model_dir.name}: unexpected log format: {first_line!r}")
550
+
551
+ return creation_dates
552
+
553
+
554
+ def build_timeline_json(
555
+ transformers_dir: Path,
556
+ threshold: float = SIM_DEFAULT,
557
+ multimodal: bool = False,
558
+ sim_method: str = "jaccard",
559
+ ) -> dict:
560
+ """Build chronological timeline with modular connections."""
561
+ # Get the standard dependency graph for connections
562
+ graph = build_graph_json(transformers_dir, threshold, multimodal, sim_method)
563
+
564
+ # Get creation dates for chronological positioning
565
+ creation_dates = get_model_creation_dates(transformers_dir)
566
+
567
+ # Enhance nodes with chronological data
568
+ for node in graph["nodes"]:
569
+ model_name = node["id"]
570
+ if model_name in creation_dates:
571
+ creation_date = creation_dates[model_name]
572
+ node.update({
573
+ "date": creation_date.isoformat(),
574
+ "year": creation_date.year,
575
+ "timestamp": creation_date.timestamp()
576
+ })
577
+ else:
578
+ # Fallback for models without date info
579
+ node.update({
580
+ "date": "2020-01-01T00:00:00", # Default date
581
+ "year": 2020,
582
+ "timestamp": datetime(2020, 1, 1).timestamp()
583
+ })
584
+
585
+ # Add timeline metadata
586
+ valid_dates = [n for n in graph["nodes"] if n["timestamp"] > 0]
587
+ if valid_dates:
588
+ min_year = min(n["year"] for n in valid_dates)
589
+ max_year = max(n["year"] for n in valid_dates)
590
+ graph["timeline_meta"] = {
591
+ "min_year": min_year,
592
+ "max_year": max_year,
593
+ "total_models": len(graph["nodes"]),
594
+ "dated_models": len(valid_dates)
595
+ }
596
+ else:
597
+ graph["timeline_meta"] = {
598
+ "min_year": 2018,
599
+ "max_year": 2024,
600
+ "total_models": len(graph["nodes"]),
601
+ "dated_models": 0
602
+ }
603
+
604
+ return graph
605
+
606
  def generate_html(graph: dict) -> str:
607
  """Return the full HTML string with inlined CSS/JS + graph JSON."""
608
  js = JS.replace("__GRAPH_DATA__", json.dumps(graph, separators=(",", ":")))
609
  return HTML.replace("__CSS__", CSS).replace("__JS__", js)
610
 
611
+ def generate_timeline_html(timeline: dict) -> str:
612
+ """Return the full HTML string for chronological timeline visualization."""
613
+ js = TIMELINE_JS.replace("__TIMELINE_DATA__", json.dumps(timeline, separators=(",", ":")))
614
+ return TIMELINE_HTML.replace("__TIMELINE_CSS__", TIMELINE_CSS).replace("__TIMELINE_JS__", js)
615
+
616
 
617
 
618
  # ────────────────────────────────────────────────────────────────────────────────
 
768
  <script>__JS__</script></body></html>
769
  """
770
 
771
+ # ────────────────────────────────────────────────────────────────────────────────
772
+ # Timeline HTML Templates
773
+ # ────────────────────────────────────────────────────────────────────────────────
774
+
775
+ TIMELINE_CSS = """
776
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');
777
+
778
+ :root{
779
+ --bg:#ffffff;
780
+ --text:#222222;
781
+ --muted:#555555;
782
+ --outline:#ffffff;
783
+ --timeline-line:#dee2e6;
784
+ --base-color:#ffbe0b;
785
+ --derived-color:#1f77b4;
786
+ --candidate-color:#e63946;
787
+ }
788
+ @media (prefers-color-scheme: dark){
789
+ :root{
790
+ --bg:#0b0d10;
791
+ --text:#e8e8e8;
792
+ --muted:#c8c8c8;
793
+ --outline:#000000;
794
+ --timeline-line:#343a40;
795
+ }
796
+ }
797
+
798
+ body{
799
+ margin:0;
800
+ font-family:'Inter',Arial,sans-serif;
801
+ background:var(--bg);
802
+ overflow:hidden;
803
+ }
804
+ svg{ width:100vw; height:100vh; }
805
+
806
+ /* Enhanced link styles for chronological flow */
807
+ .link{
808
+ stroke:#4a90e2;
809
+ stroke-opacity:0.6;
810
+ stroke-width:1.5;
811
+ fill:none;
812
+ transition: stroke-opacity 0.3s ease;
813
+ }
814
+ .link.cand{
815
+ stroke:var(--candidate-color);
816
+ stroke-width:2.5;
817
+ stroke-opacity:0.8;
818
+ stroke-dasharray: 4,4;
819
+ }
820
+ .link:hover{
821
+ stroke-opacity:1;
822
+ stroke-width:3;
823
+ }
824
+
825
+ /* Improved node label styling */
826
+ .node-label{
827
+ fill:var(--text);
828
+ pointer-events:none;
829
+ text-anchor:middle;
830
+ font-weight:600;
831
+ font-size:13px;
832
+ paint-order:stroke fill;
833
+ stroke:var(--outline);
834
+ stroke-width:3px;
835
+ cursor:default;
836
+ }
837
+
838
+ /* Enhanced node styling with better visual hierarchy */
839
+ .node.base circle{
840
+ fill:var(--base-color);
841
+ stroke:#d4a000;
842
+ stroke-width:2;
843
+ }
844
+ .node.derived circle{
845
+ fill:var(--derived-color);
846
+ stroke:#1565c0;
847
+ stroke-width:2;
848
+ }
849
+ .node.cand circle{
850
+ fill:var(--candidate-color);
851
+ stroke:#c62828;
852
+ stroke-width:2;
853
+ }
854
+
855
+ .node circle{
856
+ transition: r 0.3s ease, stroke-width 0.3s ease;
857
+ cursor:grab;
858
+ }
859
+ .node:hover circle{
860
+ r:22;
861
+ stroke-width:3;
862
+ }
863
+ .node:active{
864
+ cursor:grabbing;
865
+ }
866
+
867
+ /* Timeline axis styling */
868
+ .timeline-axis {
869
+ stroke: var(--timeline-line);
870
+ stroke-width: 3px;
871
+ stroke-opacity: 0.8;
872
+ }
873
+
874
+ .timeline-tick {
875
+ stroke: var(--timeline-line);
876
+ stroke-width: 2px;
877
+ stroke-opacity: 0.6;
878
+ }
879
+
880
+ .timeline-label {
881
+ fill: var(--muted);
882
+ font-size: 14px;
883
+ font-weight: 600;
884
+ text-anchor: middle;
885
+ }
886
+
887
+ /* Enhanced controls panel */
888
+ #controls{
889
+ position:fixed; top:20px; left:20px;
890
+ background:rgba(255,255,255,.95);
891
+ padding:20px 26px; border-radius:12px; border:1.5px solid #e0e0e0;
892
+ font-size:14px; box-shadow:0 4px 16px rgba(0,0,0,.12);
893
+ z-index: 100;
894
+ backdrop-filter: blur(8px);
895
+ max-width: 280px;
896
+ }
897
+ @media (prefers-color-scheme: dark){
898
+ #controls{
899
+ background:rgba(20,22,25,.95);
900
+ color:#e8e8e8;
901
+ border-color:#404040;
902
+ }
903
+ }
904
+
905
+ #controls label{
906
+ display:flex;
907
+ align-items:center;
908
+ margin-top:10px;
909
+ cursor:pointer;
910
+ }
911
+ #controls input[type="checkbox"]{
912
+ margin-right:8px;
913
+ cursor:pointer;
914
+ }
915
+ """
916
+
917
+ TIMELINE_JS = """
918
+ function updateVisibility() {
919
+ const show = document.getElementById('toggleRed').checked;
920
+ svg.selectAll('.link.cand').style('display', show ? null : 'none');
921
+ svg.selectAll('.node.cand').style('display', show ? null : 'none');
922
+ }
923
+ document.getElementById('toggleRed').addEventListener('change', updateVisibility);
924
+
925
+ const HF_LOGO_URI = "./static/hf-logo.png";
926
+ const timeline = __TIMELINE_DATA__;
927
+ const W = innerWidth, H = innerHeight;
928
+
929
+ // Enhanced timeline configuration for maximum horizontal spread
930
+ const MARGIN = { top: 60, right: 200, bottom: 120, left: 200 };
931
+ const CONTENT_HEIGHT = H - MARGIN.top - MARGIN.bottom;
932
+ const VERTICAL_LANES = 4; // Number of horizontal lanes for better organization
933
+
934
+ // Create SVG with zoom behavior
935
+ const svg = d3.select('#timeline-svg');
936
+ const zoomBehavior = d3.zoom()
937
+ .scaleExtent([0.1, 8])
938
+ .on('zoom', handleZoom);
939
+ svg.call(zoomBehavior);
940
+
941
+ const g = svg.append('g');
942
+
943
+ // Time scale for chronological positioning with much wider spread
944
+ const timeExtent = d3.extent(timeline.nodes.filter(d => d.timestamp > 0), d => d.timestamp);
945
+ let timeScale;
946
+
947
+ if (timeExtent[0] && timeExtent[1]) {
948
+ // Much wider timeline for maximum horizontal spread
949
+ const timeWidth = Math.max(W * 8, 8000);
950
+ timeScale = d3.scaleTime()
951
+ .domain(timeExtent.map(t => new Date(t * 1000)))
952
+ .range([MARGIN.left, timeWidth - MARGIN.right]);
953
+
954
+ // Timeline axis at the bottom
955
+ const timelineG = g.append('g').attr('class', 'timeline');
956
+ const timelineY = H - 80;
957
+
958
+ timelineG.append('line')
959
+ .attr('class', 'timeline-axis')
960
+ .attr('x1', MARGIN.left)
961
+ .attr('y1', timelineY)
962
+ .attr('x2', timeWidth - MARGIN.right)
963
+ .attr('y2', timelineY);
964
+
965
+ // Enhanced year markers with better spacing
966
+ const years = d3.timeYear.range(new Date(timeExtent[0] * 1000), new Date(timeExtent[1] * 1000 + 365*24*60*60*1000));
967
+ timelineG.selectAll('.timeline-tick')
968
+ .data(years)
969
+ .join('line')
970
+ .attr('class', 'timeline-tick')
971
+ .attr('x1', d => timeScale(d))
972
+ .attr('y1', timelineY - 15)
973
+ .attr('x2', d => timeScale(d))
974
+ .attr('y2', timelineY + 15);
975
+
976
+ timelineG.selectAll('.timeline-label')
977
+ .data(years)
978
+ .join('text')
979
+ .attr('class', 'timeline-label')
980
+ .attr('x', d => timeScale(d))
981
+ .attr('y', timelineY + 30)
982
+ .text(d => d.getFullYear());
983
+ }
984
+
985
+ function handleZoom(event) {
986
+ const { transform } = event;
987
+ g.attr('transform', transform);
988
+ }
989
+
990
+ // Enhanced curved links for better chronological flow visualization
991
+ const link = g.selectAll('path.link')
992
+ .data(timeline.links)
993
+ .join('path')
994
+ .attr('class', d => d.cand ? 'link cand' : 'link')
995
+ .attr('fill', 'none')
996
+ .attr('stroke-width', d => d.cand ? 2.5 : 1.5);
997
+
998
+ // Nodes with improved positioning strategy
999
+ const node = g.selectAll('g.node')
1000
+ .data(timeline.nodes)
1001
+ .join('g')
1002
+ .attr('class', d => `node ${d.cls}`)
1003
+ .call(d3.drag().on('start', dragStart).on('drag', dragged).on('end', dragEnd));
1004
+
1005
+ const baseSel = node.filter(d => d.cls === 'base');
1006
+ if (HF_LOGO_URI) {
1007
+ baseSel.append('image')
1008
+ .attr('href', HF_LOGO_URI)
1009
+ .attr('width', 35)
1010
+ .attr('height', 35)
1011
+ .attr('x', -17.5)
1012
+ .attr('y', -17.5)
1013
+ .on('error', function() {
1014
+ d3.select(this.parentNode).append('circle')
1015
+ .attr('r', 20).attr('fill', '#ffbe0b');
1016
+ });
1017
+ } else {
1018
+ baseSel.append('circle').attr('r', 20).attr('fill', '#ffbe0b');
1019
+ }
1020
+ node.filter(d => d.cls !== 'base').append('circle').attr('r', 18);
1021
+
1022
+ node.append('text')
1023
+ .attr('class', 'node-label')
1024
+ .attr('dy', '-2.2em')
1025
+ .text(d => d.id);
1026
+
1027
+ // Organize nodes by chronological lanes for better vertical distribution
1028
+ timeline.nodes.forEach((d, i) => {
1029
+ if (d.timestamp > 0) {
1030
+ // Assign lane based on chronological order within similar timeframes
1031
+ const yearNodes = timeline.nodes.filter(n =>
1032
+ n.timestamp > 0 &&
1033
+ Math.abs(n.timestamp - d.timestamp) < 365*24*60*60
1034
+ );
1035
+ d.lane = yearNodes.indexOf(d) % VERTICAL_LANES;
1036
+ } else {
1037
+ d.lane = i % VERTICAL_LANES;
1038
+ }
1039
+ });
1040
+
1041
+ // Enhanced force simulation for optimal horizontal chronological layout
1042
+ const sim = d3.forceSimulation(timeline.nodes)
1043
+ .force('link', d3.forceLink(timeline.links).id(d => d.id)
1044
+ .distance(d => d.cand ? 100 : 200)
1045
+ .strength(d => d.cand ? 0.1 : 0.3))
1046
+ .force('charge', d3.forceManyBody().strength(-300))
1047
+ .force('collide', d3.forceCollide(d => 45).strength(0.8));
1048
+
1049
+ // Very strong chronological X positioning for proper horizontal spread
1050
+ if (timeScale) {
1051
+ sim.force('chronological', d3.forceX(d => {
1052
+ if (d.timestamp > 0) {
1053
+ return timeScale(new Date(d.timestamp * 1000));
1054
+ }
1055
+ // Place undated models at the end
1056
+ return timeScale.range()[1] + 100;
1057
+ }).strength(0.95));
1058
+ }
1059
+
1060
+ // Organized Y positioning using lanes instead of random spread
1061
+ sim.force('lanes', d3.forceY(d => {
1062
+ const centerY = H / 2 - 100; // Position above timeline
1063
+ const laneHeight = (H - 200) / (VERTICAL_LANES + 1); // Account for timeline space
1064
+ const targetY = centerY - ((H - 200) / 2) + (d.lane + 1) * laneHeight;
1065
+ return targetY;
1066
+ }).strength(0.7));
1067
+
1068
+ // Add center force to prevent rightward drift
1069
+ sim.force('center', d3.forceCenter(timeScale ? (timeScale.range()[0] + timeScale.range()[1]) / 2 : W / 2, H / 2 - 100).strength(0.1));
1070
+
1071
+ // Custom path generator for curved links that follow chronological flow
1072
+ function linkPath(d) {
1073
+ const sourceX = d.source.x || 0;
1074
+ const sourceY = d.source.y || 0;
1075
+ const targetX = d.target.x || 0;
1076
+ const targetY = d.target.y || 0;
1077
+
1078
+ // Create curved paths for better visual flow
1079
+ const dx = targetX - sourceX;
1080
+ const dy = targetY - sourceY;
1081
+ const dr = Math.sqrt(dx * dx + dy * dy) * 0.3;
1082
+
1083
+ // Curve direction based on chronological order
1084
+ const curve = dx > 0 ? dr : -dr;
1085
+
1086
+ return `M${sourceX},${sourceY}A${dr},${dr} 0 0,1 ${targetX},${targetY}`;
1087
+ }
1088
+
1089
+ sim.on('tick', () => {
1090
+ link.attr('d', linkPath);
1091
+ node.attr('transform', d => `translate(${d.x},${d.y})`);
1092
+ });
1093
+
1094
+ function dragStart(e, d) {
1095
+ if (!e.active) sim.alphaTarget(.3).restart();
1096
+ d.fx = d.x;
1097
+ d.fy = d.y;
1098
+ }
1099
+ function dragged(e, d) {
1100
+ d.fx = e.x;
1101
+ d.fy = e.y;
1102
+ }
1103
+ function dragEnd(e, d) {
1104
+ if (!e.active) sim.alphaTarget(0);
1105
+ d.fx = d.fy = null;
1106
+ }
1107
+
1108
+ // Initialize
1109
+ updateVisibility();
1110
+
1111
+ // Auto-fit timeline view with better zoom for horizontal spread
1112
+ setTimeout(() => {
1113
+ if (timeScale && timeExtent[0] && timeExtent[1]) {
1114
+ const timeWidth = timeScale.range()[1] - timeScale.range()[0];
1115
+ const scale = Math.min((W * 0.9) / timeWidth, 1);
1116
+ const translateX = (W - timeWidth * scale) / 2;
1117
+ const translateY = 0;
1118
+
1119
+ svg.transition()
1120
+ .duration(2000)
1121
+ .call(zoomBehavior.transform,
1122
+ d3.zoomIdentity.translate(translateX, translateY).scale(scale));
1123
+ }
1124
+ }, 1500);
1125
+ """
1126
+
1127
+ TIMELINE_HTML = """
1128
+ <!DOCTYPE html>
1129
+ <html lang='en'><head><meta charset='UTF-8'>
1130
+ <title>Transformers Chronological Timeline</title>
1131
+ <style>__TIMELINE_CSS__</style></head><body>
1132
+ <div id='controls'>
1133
+ <div style='font-weight:600; margin-bottom:8px;'>Chronological Timeline</div>
1134
+ 🟑 base<br>πŸ”΅ modular<br>πŸ”΄ candidate<br>
1135
+ <label><input type="checkbox" id="toggleRed" checked> Show candidates</label>
1136
+ <div style='margin-top:10px; font-size:11px; color:var(--muted);'>
1137
+ Models positioned by creation date<br>
1138
+ Scroll & zoom to explore timeline
1139
+ </div>
1140
+ </div>
1141
+ <svg id='timeline-svg'></svg>
1142
+ <script src='https://d3js.org/d3.v7.min.js'></script>
1143
+ <script>__TIMELINE_JS__</script></body></html>
1144
+ """
1145
+
1146
  # ────────────────────────────────────────────────────────────────────────────────
1147
  # HTML writer
1148
  # ────────────────────────────────────────────────────────────────────────────────