pepijn223 HF Staff commited on
Commit
fb7e94b
·
unverified ·
1 Parent(s): 6afedde

Robot folding blog post — initial deployment

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. README.md +1 -1
  3. app/package-lock.json +0 -0
  4. app/package.json +0 -0
  5. app/src/components/Hero.astro +58 -1
  6. app/src/components/HtmlEmbed.astro +151 -5
  7. app/src/components/Image.astro +44 -24
  8. app/src/components/Reference.astro +2 -2
  9. app/src/components/Video.astro +123 -0
  10. app/src/components/trackio/TrackioWrapper.astro +11 -26
  11. app/src/components/trackio/components/Cell.svelte +182 -92
  12. app/src/components/trackio/components/FullscreenModal.svelte +194 -154
  13. app/src/components/trackio/core/adaptive-sampler.js +48 -48
  14. app/src/components/trackio/core/data-generator.js +110 -110
  15. app/src/components/trackio/renderers/ChartRendererRefactored.svelte +277 -83
  16. app/src/components/trackio/renderers/core/interaction-manager.js +119 -72
  17. app/src/components/trackio/renderers/core/path-renderer.js +105 -40
  18. app/src/components/trackio/renderers/core/zoom-manager.js +288 -0
  19. app/src/content/article.mdx +58 -26
  20. app/src/content/assets/audio/audio-example.mp3 +3 -0
  21. app/src/content/assets/image/Folding_V1.mp4 +3 -0
  22. app/src/content/assets/image/footpedal.jpg +3 -0
  23. app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2200_progress.gif +3 -0
  24. app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2500_progress.gif +3 -0
  25. app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_grid_15x10.jpg +3 -0
  26. app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_ep300_progress.gif +3 -0
  27. app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_grid_15x10.jpg +3 -0
  28. app/src/content/assets/image/maintain-the-unmaintainable.png +2 -2
  29. app/src/content/assets/image/ogp.webp +3 -0
  30. app/src/content/assets/image/openarm-mini1.jpg +3 -0
  31. app/src/content/assets/image/openarm-mini2.jpg +3 -0
  32. app/src/content/assets/image/robot_folding.png +3 -0
  33. app/src/content/assets/image/smoll-training-guide.png +2 -2
  34. app/src/content/bibliography.bib +87 -107
  35. app/src/content/chapters/demo/built-with-this.mdx +19 -10
  36. app/src/content/chapters/demo/components.mdx +1 -1
  37. app/src/content/chapters/demo/import-content.mdx +46 -21
  38. app/src/content/chapters/demo/markdown.mdx +2 -2
  39. app/src/content/chapters/demo/writing-your-content.mdx +1 -1
  40. app/src/content/chapters/folding/01-hero.mdx +35 -0
  41. app/src/content/chapters/folding/02-results.mdx +38 -0
  42. app/src/content/chapters/folding/03-hardware.mdx +79 -0
  43. app/src/content/chapters/folding/04-data-collection.mdx +28 -0
  44. app/src/content/chapters/folding/05-data-diversity.mdx +53 -0
  45. app/src/content/chapters/folding/06-training.mdx +82 -0
  46. app/src/content/chapters/folding/07-evaluation.mdx +65 -0
  47. app/src/content/chapters/folding/08-ablations.mdx +183 -0
  48. app/src/content/chapters/folding/09-learnings.mdx +42 -0
  49. app/src/content/chapters/folding/12-references.mdx +39 -0
  50. app/src/content/chapters/your-first-chapter.mdx +0 -2
.gitattributes CHANGED
@@ -16,3 +16,4 @@ package-lock.json -filter -diff -merge text
16
  # Notion imported images should NOT be in LFS (needed for Docker build)
17
  app/src/content/assets/image/image_27877f1c*.png -filter -diff -merge text
18
  app/scripts/notion-importer/output/** -filter -diff -merge text
 
 
16
  # Notion imported images should NOT be in LFS (needed for Docker build)
17
  app/src/content/assets/image/image_27877f1c*.png -filter -diff -merge text
18
  app/scripts/notion-importer/output/** -filter -diff -merge text
19
+ app/src/content/assets/image/ogp.webp filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: 'The Smol Training Playbook: The Secrets to Building World-Class LLMs'
3
  short_desc: 'A practical journey behind training SOTA LLMs'
4
  emoji: 📝
5
  colorFrom: blue
 
1
  ---
2
+ title: 'Bringing paper to life: A modern template for scientific writing'
3
  short_desc: 'A practical journey behind training SOTA LLMs'
4
  emoji: 📝
5
  colorFrom: blue
app/package-lock.json CHANGED
Binary files a/app/package-lock.json and b/app/package-lock.json differ
 
app/package.json CHANGED
Binary files a/app/package.json and b/app/package.json differ
 
app/src/components/Hero.astro CHANGED
@@ -1,5 +1,6 @@
1
  ---
2
  import HtmlEmbed from "./HtmlEmbed.astro";
 
3
 
4
  interface Props {
5
  title: string; // may contain HTML (e.g., <br/>)
@@ -98,7 +99,21 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
98
  <section class="hero">
99
  <h1 class="hero-title" set:html={title} />
100
  <div class="hero-banner">
101
- <HtmlEmbed src="banner.html" frameless />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  {description && <p class="hero-desc">{description}</p>}
103
  </div>
104
  </section>
@@ -393,6 +408,21 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
393
  </script>
394
  )}
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  <style>
397
  /* Hero (full-width) */
398
  .hero {
@@ -406,11 +436,37 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
406
  line-height: 1.1;
407
  margin: 0 0 8px;
408
  max-width: 100%;
 
409
  }
410
  .hero-banner {
411
  max-width: 980px;
412
  margin: 0 auto;
413
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  .hero-desc {
415
  color: var(--muted-color);
416
  font-style: italic;
@@ -472,6 +528,7 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
472
  }
473
  .meta-container-cell p {
474
  margin: 0;
 
475
  }
476
  .authors {
477
  margin: 0;
 
1
  ---
2
  import HtmlEmbed from "./HtmlEmbed.astro";
3
+ import announcementVideo from "../content/assets/image/Folding_V1.mp4";
4
 
5
  interface Props {
6
  title: string; // may contain HTML (e.g., <br/>)
 
99
  <section class="hero">
100
  <h1 class="hero-title" set:html={title} />
101
  <div class="hero-banner">
102
+ <div class="hero-video-wrapper">
103
+ <video id="hero-video" src={announcementVideo} autoplay loop muted playsinline style="width:100%;height:auto;border-radius:12px;"></video>
104
+ <button id="hero-mute-btn" class="mute-btn" aria-label="Toggle audio">
105
+ <svg class="icon-muted" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
106
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
107
+ <line x1="23" y1="9" x2="17" y2="15"></line>
108
+ <line x1="17" y1="9" x2="23" y2="15"></line>
109
+ </svg>
110
+ <svg class="icon-unmuted" style="display:none;" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
111
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
112
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
113
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
114
+ </svg>
115
+ </button>
116
+ </div>
117
  {description && <p class="hero-desc">{description}</p>}
118
  </div>
119
  </section>
 
408
  </script>
409
  )}
410
 
411
+ <script is:inline>
412
+ (function() {
413
+ const video = document.getElementById('hero-video');
414
+ const btn = document.getElementById('hero-mute-btn');
415
+ if (!video || !btn) return;
416
+ const iconMuted = btn.querySelector('.icon-muted');
417
+ const iconUnmuted = btn.querySelector('.icon-unmuted');
418
+ btn.addEventListener('click', () => {
419
+ video.muted = !video.muted;
420
+ iconMuted.style.display = video.muted ? 'block' : 'none';
421
+ iconUnmuted.style.display = video.muted ? 'none' : 'block';
422
+ });
423
+ })();
424
+ </script>
425
+
426
  <style>
427
  /* Hero (full-width) */
428
  .hero {
 
436
  line-height: 1.1;
437
  margin: 0 0 8px;
438
  max-width: 100%;
439
+ color: var(--text-color);
440
  }
441
  .hero-banner {
442
  max-width: 980px;
443
  margin: 0 auto;
444
  }
445
+ .hero-video-wrapper {
446
+ position: relative;
447
+ display: inline-block;
448
+ width: 100%;
449
+ }
450
+ .mute-btn {
451
+ position: absolute;
452
+ bottom: 16px;
453
+ right: 16px;
454
+ background: rgba(0, 0, 0, 0.5);
455
+ border: none;
456
+ border-radius: 50%;
457
+ width: 48px;
458
+ height: 48px;
459
+ display: flex;
460
+ align-items: center;
461
+ justify-content: center;
462
+ cursor: pointer;
463
+ color: white;
464
+ backdrop-filter: blur(4px);
465
+ transition: background 0.2s;
466
+ }
467
+ .mute-btn:hover {
468
+ background: rgba(0, 0, 0, 0.7);
469
+ }
470
  .hero-desc {
471
  color: var(--muted-color);
472
  font-style: italic;
 
528
  }
529
  .meta-container-cell p {
530
  margin: 0;
531
+ color: var(--text-color);
532
  }
533
  .authors {
534
  margin: 0;
app/src/components/HtmlEmbed.astro CHANGED
@@ -214,6 +214,7 @@ const htmlWithId =
214
  margin: 0 0 var(--block-spacing-y);
215
  z-index: var(--z-elevated);
216
  position: relative;
 
217
  }
218
 
219
  /* Wide mode - same styling as Wide.astro component */
@@ -278,8 +279,7 @@ const htmlWithId =
278
  font-size: 0.95rem;
279
  color: var(--text-color);
280
  margin: 0;
281
- padding: 0;
282
- padding-bottom: var(--spacing-1);
283
  position: relative;
284
  display: block;
285
  width: 100%;
@@ -301,11 +301,10 @@ const htmlWithId =
301
  }
302
  .html-embed__desc {
303
  text-align: left;
304
- font-size: 0.9rem;
305
  color: var(--muted-color);
306
  margin: 0;
307
- padding: 0;
308
- padding-top: var(--spacing-1);
309
  position: relative;
310
  z-index: var(--z-elevated);
311
  display: block;
@@ -489,6 +488,153 @@ const htmlWithId =
489
  width: 100% !important;
490
  }
491
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  @media print {
493
  /* Avoid breaks inside embeds */
494
  .html-embed,
 
214
  margin: 0 0 var(--block-spacing-y);
215
  z-index: var(--z-elevated);
216
  position: relative;
217
+ gap: 0;
218
  }
219
 
220
  /* Wide mode - same styling as Wide.astro component */
 
279
  font-size: 0.95rem;
280
  color: var(--text-color);
281
  margin: 0;
282
+ padding: 0 0 4px;
 
283
  position: relative;
284
  display: block;
285
  width: 100%;
 
301
  }
302
  .html-embed__desc {
303
  text-align: left;
304
+ font-size: 0.85rem;
305
  color: var(--muted-color);
306
  margin: 0;
307
+ padding: 4px 0 0;
 
308
  position: relative;
309
  z-index: var(--z-elevated);
310
  display: block;
 
488
  width: 100% !important;
489
  }
490
  }
491
+ /* ── Light-mode overrides for embedded charts ── */
492
+
493
+ /* CSS custom properties used by embeds that define :root vars */
494
+ [data-theme="light"] .html-embed__card {
495
+ --text: rgba(0,0,0,.85);
496
+ --subtext: #6b7280;
497
+ --grid: rgba(0,0,0,.10);
498
+ --border: #e2e4e9;
499
+ --card: #ffffff;
500
+ --bg: transparent;
501
+ }
502
+
503
+ /* Chrome: cards, panels, wraps */
504
+ [data-theme="light"] .html-embed__card .wrap,
505
+ [data-theme="light"] .html-embed__card .container { color: rgba(0,0,0,.85); }
506
+
507
+ [data-theme="light"] .html-embed__card .card,
508
+ [data-theme="light"] .html-embed__card .chart-wrap {
509
+ background: #fff; border-color: #e2e4e9;
510
+ }
511
+ [data-theme="light"] .html-embed__card .card-head {
512
+ border-color: #e2e4e9; color: #6b7280;
513
+ }
514
+ [data-theme="light"] .html-embed__card .chart-title { color: #6b7280; }
515
+
516
+ /* Insight boxes (statistical-analysis) */
517
+ [data-theme="light"] .html-embed__card .insight {
518
+ background: #f7f8fa; border-color: #e2e4e9; color: #6b7280;
519
+ }
520
+ [data-theme="light"] .html-embed__card .insight strong { color: rgba(0,0,0,.85); }
521
+
522
+ /* Wilson CI note (success-rates) */
523
+ [data-theme="light"] .html-embed__card .ci-note {
524
+ background: rgba(0,0,0,.02); border-color: #e2e4e9; color: #6b7280;
525
+ }
526
+ [data-theme="light"] .html-embed__card .ci-note strong { color: #374151; }
527
+
528
+ /* Legends */
529
+ [data-theme="light"] .html-embed__card .legend { border-color: #e2e4e9; }
530
+ [data-theme="light"] .html-embed__card .legend-item,
531
+ [data-theme="light"] .html-embed__card .li { color: #6b7280; }
532
+ [data-theme="light"] .html-embed__card .legend-item:hover { background: rgba(0,0,0,.04); }
533
+ [data-theme="light"] .html-embed__card .legend-label { color: #374151; }
534
+ [data-theme="light"] .html-embed__card .series-badge { color: #6b7280; }
535
+
536
+ /* Controls: buttons, toggles, sort, tabs */
537
+ [data-theme="light"] .html-embed__card .ctrl-btn,
538
+ [data-theme="light"] .html-embed__card .sort-btn {
539
+ background: #fff; border-color: #e2e4e9; color: #6b7280;
540
+ }
541
+ [data-theme="light"] .html-embed__card .ctrl-btn:hover,
542
+ [data-theme="light"] .html-embed__card .ctrl-btn.active,
543
+ [data-theme="light"] .html-embed__card .sort-btn:hover,
544
+ [data-theme="light"] .html-embed__card .sort-btn.active {
545
+ color: rgba(0,0,0,.85); border-color: #c5c8d0; background: #f3f4f6;
546
+ }
547
+ [data-theme="light"] .html-embed__card .toggle-btn {
548
+ background: #fff; border-color: #e2e4e9; color: #6b7280;
549
+ }
550
+ [data-theme="light"] .html-embed__card .toggle-btn:hover {
551
+ border-color: #9ca3af; color: rgba(0,0,0,.85);
552
+ }
553
+ [data-theme="light"] .html-embed__card .divider { background: #e2e4e9; }
554
+ [data-theme="light"] .html-embed__card .ctrl-label { color: #6b7280; }
555
+
556
+ /* Tabs (failure-analysis) */
557
+ [data-theme="light"] .html-embed__card .tab-row { border-color: #e2e4e9; }
558
+ [data-theme="light"] .html-embed__card .tab { color: #6b7280; }
559
+ [data-theme="light"] .html-embed__card .tab:hover {
560
+ color: rgba(0,0,0,.85); background: #f3f4f6;
561
+ }
562
+ [data-theme="light"] .html-embed__card .tab.active {
563
+ color: rgba(0,0,0,.85); background: #fff;
564
+ border-color: #e2e4e9; border-bottom-color: #fff;
565
+ }
566
+
567
+ /* Tab buttons (loss-curves, pi05-architecture) */
568
+ [data-theme="light"] .html-embed__card .tab-btn,
569
+ [data-theme="light"] .html-embed__card .series-btn {
570
+ background: #f3f4f6; color: #6b7280;
571
+ }
572
+ [data-theme="light"] .html-embed__card .tab-btn.active,
573
+ [data-theme="light"] .html-embed__card .series-btn.active {
574
+ background: #fff; color: rgba(0,0,0,.85); border-bottom-color: #6366f1;
575
+ }
576
+ [data-theme="light"] .html-embed__card .tab-btn:hover:not(.active),
577
+ [data-theme="light"] .html-embed__card .series-btn:hover:not(.active) {
578
+ background: #e9ebee; color: #374151;
579
+ }
580
+
581
+ /* Tooltips */
582
+ [data-theme="light"] .html-embed__card .tooltip,
583
+ [data-theme="light"] .html-embed__card .tooltip-loss,
584
+ [data-theme="light"] .html-embed__card .tooltip-arch {
585
+ background: #fff; border-color: #e2e4e9; color: rgba(0,0,0,.85);
586
+ box-shadow: 0 4px 16px rgba(0,0,0,.10);
587
+ }
588
+ [data-theme="light"] .html-embed__card .tooltip strong,
589
+ [data-theme="light"] .html-embed__card .tooltip-loss strong,
590
+ [data-theme="light"] .html-embed__card .tooltip-arch strong { color: rgba(0,0,0,.85); }
591
+ [data-theme="light"] .html-embed__card .tooltip-arch .tt-detail { color: #6b7280; }
592
+ [data-theme="light"] .html-embed__card .tooltip-arch code {
593
+ background: #f3f4f6; color: #6d28d9;
594
+ }
595
+ [data-theme="light"] .html-embed__card .tooltip-note {
596
+ border-color: #e2e4e9; color: #6b7280;
597
+ }
598
+ [data-theme="light"] .html-embed__card .tooltip-ci { color: #9ca3af; }
599
+ [data-theme="light"] .html-embed__card .note { color: #9ca3af; }
600
+
601
+ /* Experiment reference tables */
602
+ [data-theme="light"] .html-embed__card .abl-ref-toggle,
603
+ [data-theme="light"] .html-embed__card .exp-ref-toggle {
604
+ border-color: #e2e4e9; color: #6b7280;
605
+ }
606
+ [data-theme="light"] .html-embed__card .abl-ref-toggle:hover,
607
+ [data-theme="light"] .html-embed__card .exp-ref-toggle:hover {
608
+ color: rgba(0,0,0,.85); border-color: #6366f1;
609
+ }
610
+ [data-theme="light"] .html-embed__card .abl-table th,
611
+ [data-theme="light"] .html-embed__card .exp-table th {
612
+ color: #6b7280; border-color: #e2e4e9;
613
+ }
614
+ [data-theme="light"] .html-embed__card .abl-table td,
615
+ [data-theme="light"] .html-embed__card .exp-table td {
616
+ color: #374151; border-color: #f3f4f6;
617
+ }
618
+ [data-theme="light"] .html-embed__card .abl-table td:first-child,
619
+ [data-theme="light"] .html-embed__card .exp-table td:first-child {
620
+ color: rgba(0,0,0,.85);
621
+ }
622
+ [data-theme="light"] .html-embed__card .abl-table tr.s2 td,
623
+ [data-theme="light"] .html-embed__card .exp-table tr.s2 td { background: rgba(247,147,79,0.06); }
624
+ [data-theme="light"] .html-embed__card .abl-table tr.s1 td,
625
+ [data-theme="light"] .html-embed__card .exp-table tr.s1 td { background: rgba(79,142,247,0.05); }
626
+ [data-theme="light"] .html-embed__card .abl-table tr:hover td,
627
+ [data-theme="light"] .html-embed__card .exp-table tr:hover td { background: rgba(0,0,0,.03); }
628
+
629
+ /* SVG axes & grids */
630
+ [data-theme="light"] .html-embed__card .axis text { fill: #6b7280 !important; }
631
+ [data-theme="light"] .html-embed__card .axis path,
632
+ [data-theme="light"] .html-embed__card .axis line { stroke: rgba(0,0,0,.20) !important; }
633
+ [data-theme="light"] .html-embed__card .grid line { stroke: rgba(0,0,0,.08) !important; }
634
+
635
+ /* CLD / violin labels in statistical-analysis */
636
+ [data-theme="light"] .html-embed__card svg text { fill: #374151; }
637
+
638
  @media print {
639
  /* Avoid breaks inside embeds */
640
  .html-embed,
app/src/components/Image.astro CHANGED
@@ -96,10 +96,10 @@ const hasCaption =
96
  hasCaptionSlot || (typeof caption === "string" && caption.length > 0);
97
  const hasTitle = Astro.slots.has("title");
98
  const uid = `ri_${Math.random().toString(36).slice(2)}`;
99
- const dataZoomable =
100
- zoomable !== false || (imgProps as any)["data-zoomable"] ? "1" : "1";
101
  const dataDownloadable =
102
- downloadable !== false || (imgProps as any)["data-downloadable"] ? "1" : "1";
103
  const hasLink = typeof linkHref === "string" && linkHref.length > 0;
104
  const resolvedTarget = hasLink ? linkTarget || "_blank" : undefined;
105
  const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
@@ -109,8 +109,8 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
109
  ---
110
 
111
  <div
112
- class={`ri-root`}
113
- data-ri-root={uid}
114
  data-has-title={hasTitle}
115
  data-has-caption={hasCaption}
116
  >
@@ -123,7 +123,7 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
123
  <span class="img-dl-wrap">
124
  {hasLink ? (
125
  <a
126
- class="ri-link"
127
  href={linkHref}
128
  target={resolvedTarget}
129
  rel={resolvedRel}
@@ -166,7 +166,7 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
166
  </span>
167
  ) : hasLink ? (
168
  <a
169
- class="ri-link"
170
  href={linkHref}
171
  target={resolvedTarget}
172
  rel={resolvedRel}
@@ -200,7 +200,7 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
200
  <span class="img-dl-wrap">
201
  {hasLink ? (
202
  <a
203
- class="ri-link"
204
  href={linkHref}
205
  target={resolvedTarget}
206
  rel={resolvedRel}
@@ -241,7 +241,7 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
241
  </span>
242
  ) : hasLink ? (
243
  <a
244
- class="ri-link"
245
  href={linkHref}
246
  target={resolvedTarget}
247
  rel={resolvedRel}
@@ -300,7 +300,7 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
300
  };
301
 
302
  const initZoomIfNeeded = () => {
303
- if (img.getAttribute("data-zoomable") !== "1") return;
304
  const isDark =
305
  document.documentElement.getAttribute("data-theme") === "dark";
306
  const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
@@ -355,13 +355,13 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
355
  // Global zoom management to hide other Figures
356
  const setupGlobalZoomBehavior = () => {
357
  img.addEventListener("click", () => {
358
- if (img.getAttribute("data-zoomable") === "1") {
359
- // Enlever zoom-active de tous les autres ri-root
360
  document
361
- .querySelectorAll(".ri-root.zoom-active")
362
  .forEach((el) => el.classList.remove("zoom-active"));
363
 
364
- // Add zoom-active to this ri-root
365
  root.classList.add("zoom-active");
366
  }
367
  });
@@ -427,9 +427,28 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
427
  </script>
428
 
429
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  figure {
431
  margin: var(--block-spacing-y) 0;
 
 
432
  }
 
433
  figcaption {
434
  text-align: left;
435
  font-size: 0.9rem;
@@ -480,10 +499,10 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
480
  }
481
 
482
  /* Opt-in zoomable images */
483
- img[data-zoomable] {
484
  cursor: zoom-in;
485
  }
486
- .medium-zoom--opened img[data-zoomable] {
487
  cursor: zoom-out;
488
  }
489
 
@@ -495,6 +514,7 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
495
  position: relative;
496
  }
497
  .img-dl-wrap {
 
498
  position: relative;
499
  display: inline-block;
500
  }
@@ -516,20 +536,20 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
516
  }
517
 
518
  /* When an image is zoomed, hide ALL Figures on the page */
519
- :global(.medium-zoom--opened) .ri-root {
520
  opacity: 0;
521
  z-index: calc(var(--z-base) - 1);
522
  transition: opacity 0.3s ease;
523
  }
524
 
525
  /* The currently zoomed image remains visible */
526
- :global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
527
  opacity: 1;
528
  z-index: var(--z-overlay);
529
  }
530
 
531
  /* Fallback for browsers without :has() support */
532
- :global(.medium-zoom--opened) .ri-root.zoom-active {
533
  opacity: 1 !important;
534
  z-index: var(--z-overlay) !important;
535
  }
@@ -548,12 +568,12 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
548
  }
549
 
550
  /* Even for active zoomed image, hide button and caption for clean experience */
551
- :global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
552
  opacity: 0;
553
  z-index: calc(var(--z-base) - 1);
554
  }
555
 
556
- :global(.medium-zoom--opened) .ri-root.zoom-active figcaption {
557
  opacity: 0;
558
  z-index: calc(var(--z-base) - 1);
559
  }
@@ -579,11 +599,11 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
579
  }
580
 
581
  /* Conditional margins based on title and caption presence */
582
- .ri-root:not([data-has-title="true"]) {
583
  margin-top: 20px;
584
  }
585
 
586
- .ri-root:not([data-has-caption="true"]) {
587
  margin-bottom: 20px;
588
  }
589
 
@@ -595,7 +615,7 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
595
  }
596
 
597
  /* Dark mode: invert luminosity while preserving color harmony */
598
- :global([data-theme="dark"]) .ri-root img {
599
  filter: invert(0.925) hue-rotate(180deg);
600
  }
601
 
 
96
  hasCaptionSlot || (typeof caption === "string" && caption.length > 0);
97
  const hasTitle = Astro.slots.has("title");
98
  const uid = `ri_${Math.random().toString(36).slice(2)}`;
99
+ // Use booleans instead of strings to avoid truthy "0" problem
100
+ const dataZoomable = zoomable !== false || !!(imgProps as any)["data-zoomable"];
101
  const dataDownloadable =
102
+ downloadable !== false || !!(imgProps as any)["data-downloadable"];
103
  const hasLink = typeof linkHref === "string" && linkHref.length > 0;
104
  const resolvedTarget = hasLink ? linkTarget || "_blank" : undefined;
105
  const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
 
109
  ---
110
 
111
  <div
112
+ class={`image-wrapper`}
113
+ data-image-wrapper={uid}
114
  data-has-title={hasTitle}
115
  data-has-caption={hasCaption}
116
  >
 
123
  <span class="img-dl-wrap">
124
  {hasLink ? (
125
  <a
126
+ class="image-link"
127
  href={linkHref}
128
  target={resolvedTarget}
129
  rel={resolvedRel}
 
166
  </span>
167
  ) : hasLink ? (
168
  <a
169
+ class="image-link"
170
  href={linkHref}
171
  target={resolvedTarget}
172
  rel={resolvedRel}
 
200
  <span class="img-dl-wrap">
201
  {hasLink ? (
202
  <a
203
+ class="image-link"
204
  href={linkHref}
205
  target={resolvedTarget}
206
  rel={resolvedRel}
 
241
  </span>
242
  ) : hasLink ? (
243
  <a
244
+ class="image-link"
245
  href={linkHref}
246
  target={resolvedTarget}
247
  rel={resolvedRel}
 
300
  };
301
 
302
  const initZoomIfNeeded = () => {
303
+ if (img.getAttribute("data-zoomable") !== "true") return;
304
  const isDark =
305
  document.documentElement.getAttribute("data-theme") === "dark";
306
  const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
 
355
  // Global zoom management to hide other Figures
356
  const setupGlobalZoomBehavior = () => {
357
  img.addEventListener("click", () => {
358
+ if (img.getAttribute("data-zoomable") === "true") {
359
+ // Remove zoom-active from all other image wrappers
360
  document
361
+ .querySelectorAll(".image-wrapper.zoom-active")
362
  .forEach((el) => el.classList.remove("zoom-active"));
363
 
364
+ // Add zoom-active to this image wrapper
365
  root.classList.add("zoom-active");
366
  }
367
  });
 
427
  </script>
428
 
429
  <style>
430
+ .image-wrapper {
431
+ display: block;
432
+ width: 100%;
433
+ }
434
+
435
+ .image-link {
436
+ display: block;
437
+ width: 100%;
438
+ }
439
+
440
+ .image-wrapper img {
441
+ display: block;
442
+ width: 100%;
443
+ height: auto;
444
+ }
445
+
446
  figure {
447
  margin: var(--block-spacing-y) 0;
448
+ display: block;
449
+ width: 100%;
450
  }
451
+
452
  figcaption {
453
  text-align: left;
454
  font-size: 0.9rem;
 
499
  }
500
 
501
  /* Opt-in zoomable images */
502
+ img[data-zoomable="true"] {
503
  cursor: zoom-in;
504
  }
505
+ .medium-zoom--opened img[data-zoomable="true"] {
506
  cursor: zoom-out;
507
  }
508
 
 
514
  position: relative;
515
  }
516
  .img-dl-wrap {
517
+ width: 100%;
518
  position: relative;
519
  display: inline-block;
520
  }
 
536
  }
537
 
538
  /* When an image is zoomed, hide ALL Figures on the page */
539
+ :global(.medium-zoom--opened) .image-wrapper {
540
  opacity: 0;
541
  z-index: calc(var(--z-base) - 1);
542
  transition: opacity 0.3s ease;
543
  }
544
 
545
  /* The currently zoomed image remains visible */
546
+ :global(.medium-zoom--opened) .image-wrapper:has(.medium-zoom--opened) {
547
  opacity: 1;
548
  z-index: var(--z-overlay);
549
  }
550
 
551
  /* Fallback for browsers without :has() support */
552
+ :global(.medium-zoom--opened) .image-wrapper.zoom-active {
553
  opacity: 1 !important;
554
  z-index: var(--z-overlay) !important;
555
  }
 
568
  }
569
 
570
  /* Even for active zoomed image, hide button and caption for clean experience */
571
+ :global(.medium-zoom--opened) .image-wrapper.zoom-active .img-dl-btn {
572
  opacity: 0;
573
  z-index: calc(var(--z-base) - 1);
574
  }
575
 
576
+ :global(.medium-zoom--opened) .image-wrapper.zoom-active figcaption {
577
  opacity: 0;
578
  z-index: calc(var(--z-base) - 1);
579
  }
 
599
  }
600
 
601
  /* Conditional margins based on title and caption presence */
602
+ .image-wrapper:not([data-has-title="true"]) {
603
  margin-top: 20px;
604
  }
605
 
606
+ .image-wrapper:not([data-has-caption="true"]) {
607
  margin-bottom: 20px;
608
  }
609
 
 
615
  }
616
 
617
  /* Dark mode: invert luminosity while preserving color harmony */
618
+ :global([data-theme="dark"]) .image-wrapper img {
619
  filter: invert(0.925) hue-rotate(180deg);
620
  }
621
 
app/src/components/Reference.astro CHANGED
@@ -40,11 +40,11 @@ const { id, caption } = Astro.props as Props;
40
  margin-bottom: 0;
41
  }
42
 
43
- .reference__content :global(.ri-root) {
44
  margin-bottom: 0;
45
  }
46
 
47
- .reference__content :global(.ri-root) :global(.reference__caption) {
48
  margin-top: 0;
49
  }
50
 
 
40
  margin-bottom: 0;
41
  }
42
 
43
+ .reference__content :global(.image-wrapper) {
44
  margin-bottom: 0;
45
  }
46
 
47
+ .reference__content :global(.image-wrapper) :global(.reference__caption) {
48
  margin-top: 0;
49
  }
50
 
app/src/components/Video.astro ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ interface Props {
3
+ src: string;
4
+ }
5
+ const { src } = Astro.props;
6
+ const id = `video-${Math.random().toString(36).slice(2, 9)}`;
7
+ ---
8
+
9
+ <div class="video-player" data-video-player={id}>
10
+ <video id={id} src={src} controls muted preload="auto" playsinline style="width:100%; border-radius: 8px; display: block;" />
11
+ <div class="speed-controls">
12
+ <span class="speed-label">Speed:</span>
13
+ <button class="speed-btn active" data-speed="1">1x</button>
14
+ <button class="speed-btn" data-speed="2">2x</button>
15
+ <button class="speed-btn" data-speed="4">4x</button>
16
+ <button class="speed-btn" data-speed="8">8x</button>
17
+ <button class="speed-btn" data-speed="16">16x</button>
18
+ </div>
19
+ </div>
20
+
21
+ <script>
22
+ document.querySelectorAll<HTMLElement>('[data-video-player]').forEach(player => {
23
+ const videoId = player.dataset.videoPlayer!;
24
+ const video = document.getElementById(videoId) as HTMLVideoElement;
25
+ if (!video) return;
26
+
27
+ let speed = 1;
28
+ let rafId: number | null = null;
29
+ let lastTime: number | null = null;
30
+ let seeking = false;
31
+
32
+ function stopSeekLoop() {
33
+ if (rafId !== null) {
34
+ cancelAnimationFrame(rafId);
35
+ rafId = null;
36
+ }
37
+ lastTime = null;
38
+ seeking = false;
39
+ }
40
+
41
+ function startSeekLoop() {
42
+ stopSeekLoop();
43
+ if (speed <= 2) return;
44
+
45
+ seeking = true;
46
+ video.pause();
47
+ lastTime = performance.now();
48
+
49
+ function step(now: number) {
50
+ if (!seeking || lastTime === null) return;
51
+ const dt = (now - lastTime) / 1000;
52
+ lastTime = now;
53
+ video.currentTime = Math.min(video.currentTime + dt * speed, video.duration);
54
+ if (video.currentTime >= video.duration) {
55
+ stopSeekLoop();
56
+ return;
57
+ }
58
+ rafId = requestAnimationFrame(step);
59
+ }
60
+ rafId = requestAnimationFrame(step);
61
+ }
62
+
63
+ player.querySelectorAll<HTMLButtonElement>('.speed-btn').forEach(btn => {
64
+ btn.addEventListener('click', () => {
65
+ speed = parseFloat(btn.dataset.speed || '1');
66
+ player.querySelectorAll('.speed-btn').forEach(b => b.classList.remove('active'));
67
+ btn.classList.add('active');
68
+
69
+ if (speed <= 2) {
70
+ stopSeekLoop();
71
+ video.playbackRate = speed;
72
+ if (video.paused && video.currentTime < video.duration) video.play();
73
+ } else {
74
+ video.playbackRate = 1;
75
+ startSeekLoop();
76
+ }
77
+ });
78
+ });
79
+
80
+ video.addEventListener('play', () => {
81
+ if (speed > 2) startSeekLoop();
82
+ });
83
+
84
+ video.addEventListener('pause', () => {
85
+ if (speed > 2 && !seeking) stopSeekLoop();
86
+ });
87
+ });
88
+ </script>
89
+
90
+ <style>
91
+ .video-player {
92
+ position: relative;
93
+ }
94
+ .speed-controls {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 6px;
98
+ margin-top: 8px;
99
+ }
100
+ .speed-label {
101
+ font-size: 0.8rem;
102
+ color: var(--text-color-secondary, #888);
103
+ margin-right: 2px;
104
+ }
105
+ .speed-btn {
106
+ font-size: 0.75rem;
107
+ padding: 3px 10px;
108
+ border-radius: 4px;
109
+ border: 1px solid var(--border-color, #ddd);
110
+ background: var(--surface-bg, #f5f5f5);
111
+ color: var(--text-color, #333);
112
+ cursor: pointer;
113
+ transition: background 0.15s, border-color 0.15s;
114
+ }
115
+ .speed-btn:hover {
116
+ border-color: var(--text-color-secondary, #888);
117
+ }
118
+ .speed-btn.active {
119
+ background: var(--text-color, #333);
120
+ color: var(--surface-bg, #fff);
121
+ border-color: var(--text-color, #333);
122
+ }
123
+ </style>
app/src/components/trackio/TrackioWrapper.astro CHANGED
@@ -192,7 +192,7 @@ import Trackio from "./Trackio.svelte";
192
 
193
  // Function to generate a new simulated metric value
194
  function generateSimulatedValue(step, metric) {
195
- const baseProgress = Math.min(1, step / 100); // Normalise sur 100 steps
196
 
197
  if (metric === "loss") {
198
  // Loss that decreases with noise
@@ -214,7 +214,7 @@ import Trackio from "./Trackio.svelte";
214
  clearInterval(simulationInterval);
215
  }
216
 
217
- // Générer un nouveau nom de run
218
  const adjectives = [
219
  "live",
220
  "real-time",
@@ -235,19 +235,19 @@ import Trackio from "./Trackio.svelte";
235
  adjectives[Math.floor(Math.random() * adjectives.length)];
236
  const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
237
  currentSimulationRun = `${randomAdj}-${randomNoun}-${Date.now().toString().slice(-4)}`;
238
- currentStep = 1; // Commencer à step 1
239
 
240
  console.log(`Starting simulation for run: ${currentSimulationRun}`);
241
 
242
- // Interface UI
243
  startSimulationBtn.style.display = "none";
244
  stopSimulationBtn.style.display = "inline-flex";
245
  startSimulationBtn.disabled = true;
246
 
247
- // Ajouter le premier point
248
  addSimulationStep();
249
 
250
- // Continuer chaque seconde
251
  simulationInterval = setInterval(() => {
252
  currentStep++;
253
  addSimulationStep();
@@ -256,7 +256,7 @@ import Trackio from "./Trackio.svelte";
256
  if (currentStep > 200) {
257
  stopSimulation();
258
  }
259
- }, 1000); // Chaque seconde
260
  }
261
 
262
  // Function to add a new data point
@@ -274,7 +274,7 @@ import Trackio from "./Trackio.svelte";
274
  newDataPoint,
275
  );
276
 
277
- // Ajouter le point via l'instance Trackio
278
  if (
279
  typeof trackioEl.__trackioInstance.addLiveDataPoint === "function"
280
  ) {
@@ -310,14 +310,14 @@ import Trackio from "./Trackio.svelte";
310
  startSimulationBtn.addEventListener("click", startSimulation);
311
  stopSimulationBtn.addEventListener("click", stopSimulation);
312
 
313
- // Arrêter la simulation si l'utilisateur quitte la page
314
  window.addEventListener("beforeunload", stopSimulation);
315
 
316
  // Randomize data handler - now uses the store
317
  randomizeBtn.addEventListener("click", () => {
318
  console.log("Randomize button clicked - triggering jitter via store"); // Debug log
319
 
320
- // Arrêter la simulation en cours si elle tourne
321
  if (simulationInterval) {
322
  stopSimulation();
323
  }
@@ -414,22 +414,6 @@ import Trackio from "./Trackio.svelte";
414
  color: var(--text-color);
415
  }
416
 
417
- .theme-select {
418
- padding: 6px 12px;
419
- border: 1px solid var(--border-color);
420
- border-radius: 4px;
421
- background: var(--input-bg, var(--surface-bg));
422
- color: var(--text-color);
423
- font-size: 14px;
424
- cursor: pointer;
425
- transition: border-color 0.15s ease;
426
- }
427
-
428
- .theme-select:focus {
429
- outline: none;
430
- border-color: var(--accent-color, #007acc);
431
- }
432
-
433
  .scale-controls {
434
  display: flex;
435
  align-items: center;
@@ -483,6 +467,7 @@ import Trackio from "./Trackio.svelte";
483
  width: 100%;
484
  margin-top: 10px;
485
  border: 1px solid var(--border-color);
 
486
  padding: 24px 12px;
487
  }
488
 
 
192
 
193
  // Function to generate a new simulated metric value
194
  function generateSimulatedValue(step, metric) {
195
+ const baseProgress = Math.min(1, step / 100); // Normalize over 100 steps
196
 
197
  if (metric === "loss") {
198
  // Loss that decreases with noise
 
214
  clearInterval(simulationInterval);
215
  }
216
 
217
+ // Generate a new run name
218
  const adjectives = [
219
  "live",
220
  "real-time",
 
235
  adjectives[Math.floor(Math.random() * adjectives.length)];
236
  const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
237
  currentSimulationRun = `${randomAdj}-${randomNoun}-${Date.now().toString().slice(-4)}`;
238
+ currentStep = 1; // Start at step 1
239
 
240
  console.log(`Starting simulation for run: ${currentSimulationRun}`);
241
 
242
+ // UI interface
243
  startSimulationBtn.style.display = "none";
244
  stopSimulationBtn.style.display = "inline-flex";
245
  startSimulationBtn.disabled = true;
246
 
247
+ // Add the first point
248
  addSimulationStep();
249
 
250
+ // Continue every second
251
  simulationInterval = setInterval(() => {
252
  currentStep++;
253
  addSimulationStep();
 
256
  if (currentStep > 200) {
257
  stopSimulation();
258
  }
259
+ }, 1000); // Every second
260
  }
261
 
262
  // Function to add a new data point
 
274
  newDataPoint,
275
  );
276
 
277
+ // Add the point via the Trackio instance
278
  if (
279
  typeof trackioEl.__trackioInstance.addLiveDataPoint === "function"
280
  ) {
 
310
  startSimulationBtn.addEventListener("click", startSimulation);
311
  stopSimulationBtn.addEventListener("click", stopSimulation);
312
 
313
+ // Stop the simulation if the user leaves the page
314
  window.addEventListener("beforeunload", stopSimulation);
315
 
316
  // Randomize data handler - now uses the store
317
  randomizeBtn.addEventListener("click", () => {
318
  console.log("Randomize button clicked - triggering jitter via store"); // Debug log
319
 
320
+ // Stop the current simulation if it's running
321
  if (simulationInterval) {
322
  stopSimulation();
323
  }
 
414
  color: var(--text-color);
415
  }
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  .scale-controls {
418
  display: flex;
419
  align-items: center;
 
467
  width: 100%;
468
  margin-top: 10px;
469
  border: 1px solid var(--border-color);
470
+ border-radius: 8px;
471
  padding: 24px 12px;
472
  }
473
 
app/src/components/trackio/components/Cell.svelte CHANGED
@@ -1,19 +1,19 @@
1
  <script>
2
- import ChartRenderer from '../renderers/ChartRendererRefactored.svelte';
3
- import ChartTooltip from '../renderers/ChartTooltip.svelte';
4
- import { formatAbbrev } from '../core/chart-utils.js';
5
-
6
  // Props
7
  export let metricKey;
8
  export let titleText;
9
  export let wide = false;
10
- export let variant = 'classic';
11
  export let normalizeLoss = true;
12
  export let logScaleX = false;
13
  export let smoothing = false;
14
  export let metricData = {}; // { run -> [{step,value}] } - smoothed data
15
  export let rawMetricData = {}; // { run -> [{step,value}] } - original data for background when smoothing
16
- export let colorForRun = (name) => '#999';
17
  export let hostEl = null;
18
 
19
  // Navigation props
@@ -23,105 +23,131 @@
23
  // Component state
24
  let root;
25
  let chartRenderer; // Reference to ChartRenderer component
26
-
27
  // Tooltip state
28
  let tooltipVisible = false;
29
  let tooltipX = -9999;
30
  let tooltipY = -9999;
31
- let tooltipTitle = '';
32
- let tooltipSubtitle = '';
33
  let tooltipEntries = [];
34
-
 
 
 
35
  // Handlers
36
  function openFullscreen() {
37
  if (onOpenModal) {
38
  onOpenModal(currentIndex);
39
  }
40
  }
41
-
 
 
 
 
 
 
 
 
 
 
42
  function handleChartHover(data) {
43
- console.log('🎯 Cell.svelte handleChartHover called with:', data);
44
  const { step, entries, position } = data;
45
-
46
  if (entries.length) {
47
  // Use global mouse coordinates for tooltip positioning
48
- const trackioEl = hostEl.closest('.trackio');
49
  const trackioRect = trackioEl.getBoundingClientRect();
50
-
51
  // Position tooltip near global cursor with small offset
52
- const relativeX = (position.globalX || position.x) - trackioRect.left + 15;
 
53
  const relativeY = (position.globalY || position.y) - trackioRect.top + 15;
54
-
55
  tooltipVisible = true;
56
  tooltipX = Math.round(relativeX);
57
  tooltipY = Math.round(relativeY);
58
  tooltipTitle = `Step ${formatAbbrev(step)}`;
59
  tooltipSubtitle = titleText;
60
  tooltipEntries = entries;
61
-
62
- console.log('📍 Tooltip state updated:', { tooltipVisible, tooltipX, tooltipY, tooltipTitle, entriesCount: tooltipEntries.length });
63
-
 
 
 
 
 
 
64
  // Dispatch to host for cross-cell synchronization
65
- try {
66
- hostEl && hostEl.dispatchEvent(new CustomEvent('trackio-hover-step', {
67
- detail: { step, sourceMetric: metricKey }
68
- }));
69
- } catch(_) {}
 
 
 
70
  }
71
  }
72
-
73
  function handleChartLeave() {
74
  tooltipVisible = false;
75
  tooltipX = -9999;
76
  tooltipY = -9999;
77
-
78
  // Dispatch leave event
79
- try {
80
- hostEl && hostEl.dispatchEvent(new CustomEvent('trackio-hover-clear', {
81
- detail: { sourceMetric: metricKey }
82
- }));
83
- } catch(_) {}
 
 
 
84
  }
85
-
86
  // External hover synchronization
87
  function setupExternalHover() {
88
  if (!root || root.__syncAttached || !hostEl) return;
89
-
90
- hostEl.addEventListener('trackio-hover-step', (ev) => {
91
  const d = ev && ev.detail;
92
  if (!d || !chartRenderer) return;
93
-
94
  // Don't sync to self - avoid infinite loops
95
  if (d.sourceMetric === metricKey) return;
96
-
97
  // Show hover line at the specified step
98
  chartRenderer.showHoverLine(d.step);
99
  });
100
-
101
- hostEl.addEventListener('trackio-hover-clear', (ev) => {
102
  if (!chartRenderer) return;
103
-
104
  // Don't sync to self
105
  const d = ev && ev.detail;
106
  if (d && d.sourceMetric === metricKey) return;
107
-
108
  // Hide hover line
109
  chartRenderer.hideHoverLine();
110
  });
111
-
112
  root.__syncAttached = true;
113
  }
114
-
115
  $: if (root && hostEl) {
116
  setupExternalHover();
117
  }
118
  </script>
119
 
120
- <div
121
- class="cell {wide ? 'cell--wide' : ''}"
122
- bind:this={root}
123
- data-metric={metricKey}
124
- data-title={titleText}
125
  data-variant={variant}
126
  >
127
  <div class="cell-bg"></div>
@@ -131,18 +157,32 @@
131
  <div class="cell-title">
132
  {titleText}
133
  </div>
134
- <button
135
- class="cell-fullscreen-btn"
136
- type="button"
137
- on:click={openFullscreen}
138
- title="Fullscreen"
139
- >
140
- <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
141
- <path d="M4 9V4h5v2H6v3H4zm10-5h5v5h-2V6h-3V4zM6 18h3v2H4v-5h2v3zm12-3h2v5h-5v-2h3v-3z"/>
142
- </svg>
143
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  </div>
145
-
146
  <div class="cell-body">
147
  <ChartRenderer
148
  bind:this={chartRenderer}
@@ -158,6 +198,8 @@
158
  {hostEl}
159
  width={800}
160
  height={150}
 
 
161
  onHover={handleChartHover}
162
  onLeave={handleChartLeave}
163
  />
@@ -176,13 +218,11 @@
176
  parentElement={root}
177
  />
178
 
179
-
180
-
181
  <style>
182
  /* =========================
183
  CELL BASE STYLES
184
  ========================= */
185
-
186
  :global(.trackio .cell) {
187
  border: 1px solid var(--trackio-cell-border);
188
  border-radius: 10px;
@@ -191,7 +231,7 @@
191
  flex-direction: column;
192
  position: relative;
193
  }
194
-
195
  /* Default cell background - hidden */
196
  :global(.trackio .cell-bg) {
197
  position: absolute;
@@ -201,7 +241,7 @@
201
  border-radius: 4px;
202
  display: none;
203
  }
204
-
205
  /* Default cell corners - hidden */
206
  :global(.trackio .cell-corners) {
207
  position: absolute;
@@ -211,7 +251,7 @@
211
  display: none;
212
  opacity: 0.85;
213
  }
214
-
215
  :global(.trackio .cell-inner) {
216
  position: relative;
217
  z-index: 2;
@@ -219,28 +259,47 @@
219
  display: flex;
220
  flex-direction: column;
221
  }
222
-
223
  /* Oblivion theme: adjust inner padding to account for corners and gap */
224
  :global(.trackio.theme--oblivion .cell-inner) {
225
- padding: var(--trackio-oblivion-hud-corner-size, 8px) 12px 10px var(--trackio-oblivion-hud-gap, 10px);
 
226
  }
227
-
228
  /* Oblivion theme: show background and corners with proper styling */
229
  :global(.trackio.theme--oblivion .cell-bg) {
230
  display: block !important;
231
- background:
232
- radial-gradient(1200px 200px at 20% -10%, rgba(0,0,0,.05), transparent 80%),
233
- radial-gradient(900px 200px at 80% 110%, rgba(0,0,0,.05), transparent 80%);
 
 
 
 
 
 
 
234
  }
235
-
236
  /* Dark mode: richer gradient for Oblivion */
237
  :global([data-theme="dark"]) :global(.trackio.theme--oblivion .cell-bg) {
238
- background:
239
- radial-gradient(1400px 260px at 20% -10%, color-mix(in srgb, #ffffff 6.5%, transparent), transparent 80%),
240
- radial-gradient(1100px 240px at 80% 110%, color-mix(in srgb, #ffffff 6%, transparent), transparent 80%),
241
- linear-gradient(180deg, color-mix(in srgb, #ffffff 3.5%, transparent), transparent 45%);
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
-
244
  :global(.trackio.theme--oblivion .cell-corners) {
245
  display: block !important;
246
  inset: 6px;
@@ -256,7 +315,7 @@
256
  opacity: 1;
257
  z-index: 3;
258
  }
259
-
260
  /* Dark mode: bright corners for Oblivion */
261
  :global([data-theme="dark"]) :global(.trackio.theme--oblivion .cell-corners) {
262
  background:
@@ -269,47 +328,48 @@
269
  linear-gradient(#ffffff, #ffffff) bottom right / 8px 1px no-repeat,
270
  linear-gradient(#ffffff, #ffffff) bottom right / 1px 8px no-repeat;
271
  }
272
-
273
  :global(.trackio .cell-header) {
274
- padding: 0 0px 10px 10px;
275
  display: flex;
276
  align-items: center;
277
  justify-content: space-between;
278
  gap: 8px;
279
  }
280
-
281
  /* Oblivion theme: adjust header padding */
282
  :global(.trackio.theme--oblivion .cell-header) {
283
  padding: 5px 0px 18px 12px;
284
  }
285
-
286
  :global(.trackio .cell-title) {
287
  font-size: 13px;
288
  font-weight: 700;
289
  color: var(--trackio-text-primary);
290
  font-family: var(--trackio-font-family);
291
  }
292
-
293
  :global(.trackio .cell-body) {
294
  position: relative;
295
  width: 100%;
296
  overflow: hidden;
297
  }
298
-
299
  /* Oblivion theme overrides */
300
  :global(.trackio.theme--oblivion .cell) {
301
  border: none !important;
302
  background: transparent !important;
303
  }
304
-
305
  :global(.trackio.theme--classic .cell) {
306
  border: 1px solid var(--trackio-cell-border) !important;
307
  background: var(--trackio-cell-background) !important;
308
  border-radius: 10px !important;
309
  }
310
-
311
  :global(.trackio.theme--oblivion .cell-title) {
312
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
313
  letter-spacing: 0.12em !important;
314
  text-transform: uppercase !important;
315
  font-weight: 800 !important;
@@ -317,7 +377,7 @@
317
  position: relative;
318
  padding-left: 14px;
319
  }
320
-
321
  /* Oblivion theme: add indicator dot before title */
322
  :global(.trackio.theme--oblivion .cell-title)::before {
323
  content: "";
@@ -334,13 +394,12 @@
334
  opacity: 0.6;
335
  }
336
 
337
-
338
  /* Ghost hover effect */
339
  :global(.trackio.hovering .ghost) {
340
  opacity: 0.2;
341
  transition: opacity 0.15s ease;
342
  }
343
-
344
  /* Specific ghost effect for raw lines when smoothing is active */
345
  :global(.trackio.hovering path.raw-line.ghost) {
346
  opacity: 0.1;
@@ -351,6 +410,13 @@
351
  grid-column: 1 / -1;
352
  }
353
 
 
 
 
 
 
 
 
354
  /* Fullscreen button */
355
  .cell-fullscreen-btn {
356
  display: inline-flex;
@@ -366,14 +432,38 @@
366
  border-radius: 6px;
367
  transition: opacity 0.15s ease;
368
  }
369
-
370
  .cell-fullscreen-btn:hover {
371
  opacity: 1;
372
  }
373
-
374
  .cell-fullscreen-btn svg {
375
  width: 18px;
376
  height: 18px;
377
  fill: var(--trackio-chart-axis-text);
378
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  </style>
 
1
  <script>
2
+ import ChartRenderer from "../renderers/ChartRendererRefactored.svelte";
3
+ import ChartTooltip from "../renderers/ChartTooltip.svelte";
4
+ import { formatAbbrev } from "../core/chart-utils.js";
5
+
6
  // Props
7
  export let metricKey;
8
  export let titleText;
9
  export let wide = false;
10
+ export let variant = "classic";
11
  export let normalizeLoss = true;
12
  export let logScaleX = false;
13
  export let smoothing = false;
14
  export let metricData = {}; // { run -> [{step,value}] } - smoothed data
15
  export let rawMetricData = {}; // { run -> [{step,value}] } - original data for background when smoothing
16
+ export let colorForRun = (name) => "#999";
17
  export let hostEl = null;
18
 
19
  // Navigation props
 
23
  // Component state
24
  let root;
25
  let chartRenderer; // Reference to ChartRenderer component
26
+
27
  // Tooltip state
28
  let tooltipVisible = false;
29
  let tooltipX = -9999;
30
  let tooltipY = -9999;
31
+ let tooltipTitle = "";
32
+ let tooltipSubtitle = "";
33
  let tooltipEntries = [];
34
+
35
+ // Zoom state
36
+ let hasZoom = false;
37
+
38
  // Handlers
39
  function openFullscreen() {
40
  if (onOpenModal) {
41
  onOpenModal(currentIndex);
42
  }
43
  }
44
+
45
+ function resetZoom() {
46
+ if (chartRenderer) {
47
+ chartRenderer.resetZoom(true);
48
+ }
49
+ }
50
+
51
+ function handleZoomChange({ hasMoved }) {
52
+ hasZoom = hasMoved;
53
+ }
54
+
55
  function handleChartHover(data) {
56
+ console.log("🎯 Cell.svelte handleChartHover called with:", data);
57
  const { step, entries, position } = data;
58
+
59
  if (entries.length) {
60
  // Use global mouse coordinates for tooltip positioning
61
+ const trackioEl = hostEl.closest(".trackio");
62
  const trackioRect = trackioEl.getBoundingClientRect();
63
+
64
  // Position tooltip near global cursor with small offset
65
+ const relativeX =
66
+ (position.globalX || position.x) - trackioRect.left + 15;
67
  const relativeY = (position.globalY || position.y) - trackioRect.top + 15;
68
+
69
  tooltipVisible = true;
70
  tooltipX = Math.round(relativeX);
71
  tooltipY = Math.round(relativeY);
72
  tooltipTitle = `Step ${formatAbbrev(step)}`;
73
  tooltipSubtitle = titleText;
74
  tooltipEntries = entries;
75
+
76
+ console.log("📍 Tooltip state updated:", {
77
+ tooltipVisible,
78
+ tooltipX,
79
+ tooltipY,
80
+ tooltipTitle,
81
+ entriesCount: tooltipEntries.length,
82
+ });
83
+
84
  // Dispatch to host for cross-cell synchronization
85
+ try {
86
+ hostEl &&
87
+ hostEl.dispatchEvent(
88
+ new CustomEvent("trackio-hover-step", {
89
+ detail: { step, sourceMetric: metricKey },
90
+ }),
91
+ );
92
+ } catch (_) {}
93
  }
94
  }
95
+
96
  function handleChartLeave() {
97
  tooltipVisible = false;
98
  tooltipX = -9999;
99
  tooltipY = -9999;
100
+
101
  // Dispatch leave event
102
+ try {
103
+ hostEl &&
104
+ hostEl.dispatchEvent(
105
+ new CustomEvent("trackio-hover-clear", {
106
+ detail: { sourceMetric: metricKey },
107
+ }),
108
+ );
109
+ } catch (_) {}
110
  }
111
+
112
  // External hover synchronization
113
  function setupExternalHover() {
114
  if (!root || root.__syncAttached || !hostEl) return;
115
+
116
+ hostEl.addEventListener("trackio-hover-step", (ev) => {
117
  const d = ev && ev.detail;
118
  if (!d || !chartRenderer) return;
119
+
120
  // Don't sync to self - avoid infinite loops
121
  if (d.sourceMetric === metricKey) return;
122
+
123
  // Show hover line at the specified step
124
  chartRenderer.showHoverLine(d.step);
125
  });
126
+
127
+ hostEl.addEventListener("trackio-hover-clear", (ev) => {
128
  if (!chartRenderer) return;
129
+
130
  // Don't sync to self
131
  const d = ev && ev.detail;
132
  if (d && d.sourceMetric === metricKey) return;
133
+
134
  // Hide hover line
135
  chartRenderer.hideHoverLine();
136
  });
137
+
138
  root.__syncAttached = true;
139
  }
140
+
141
  $: if (root && hostEl) {
142
  setupExternalHover();
143
  }
144
  </script>
145
 
146
+ <div
147
+ class="cell {wide ? 'cell--wide' : ''}"
148
+ bind:this={root}
149
+ data-metric={metricKey}
150
+ data-title={titleText}
151
  data-variant={variant}
152
  >
153
  <div class="cell-bg"></div>
 
157
  <div class="cell-title">
158
  {titleText}
159
  </div>
160
+ <div class="cell-header-buttons">
161
+ {#if hasZoom}
162
+ <button
163
+ class="cell-reset-btn"
164
+ type="button"
165
+ on:click={resetZoom}
166
+ title="Reset zoom"
167
+ >
168
+ Reset
169
+ </button>
170
+ {/if}
171
+ <button
172
+ class="cell-fullscreen-btn"
173
+ type="button"
174
+ on:click={openFullscreen}
175
+ title="Fullscreen"
176
+ >
177
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
178
+ <path
179
+ d="M4 9V4h5v2H6v3H4zm10-5h5v5h-2V6h-3V4zM6 18h3v2H4v-5h2v3zm12-3h2v5h-5v-2h3v-3z"
180
+ />
181
+ </svg>
182
+ </button>
183
+ </div>
184
  </div>
185
+
186
  <div class="cell-body">
187
  <ChartRenderer
188
  bind:this={chartRenderer}
 
198
  {hostEl}
199
  width={800}
200
  height={150}
201
+ enableZoom={true}
202
+ onZoomChange={handleZoomChange}
203
  onHover={handleChartHover}
204
  onLeave={handleChartLeave}
205
  />
 
218
  parentElement={root}
219
  />
220
 
 
 
221
  <style>
222
  /* =========================
223
  CELL BASE STYLES
224
  ========================= */
225
+
226
  :global(.trackio .cell) {
227
  border: 1px solid var(--trackio-cell-border);
228
  border-radius: 10px;
 
231
  flex-direction: column;
232
  position: relative;
233
  }
234
+
235
  /* Default cell background - hidden */
236
  :global(.trackio .cell-bg) {
237
  position: absolute;
 
241
  border-radius: 4px;
242
  display: none;
243
  }
244
+
245
  /* Default cell corners - hidden */
246
  :global(.trackio .cell-corners) {
247
  position: absolute;
 
251
  display: none;
252
  opacity: 0.85;
253
  }
254
+
255
  :global(.trackio .cell-inner) {
256
  position: relative;
257
  z-index: 2;
 
259
  display: flex;
260
  flex-direction: column;
261
  }
262
+
263
  /* Oblivion theme: adjust inner padding to account for corners and gap */
264
  :global(.trackio.theme--oblivion .cell-inner) {
265
+ padding: var(--trackio-oblivion-hud-corner-size, 8px) 12px 10px
266
+ var(--trackio-oblivion-hud-gap, 10px);
267
  }
268
+
269
  /* Oblivion theme: show background and corners with proper styling */
270
  :global(.trackio.theme--oblivion .cell-bg) {
271
  display: block !important;
272
+ background: radial-gradient(
273
+ 1200px 200px at 20% -10%,
274
+ rgba(0, 0, 0, 0.05),
275
+ transparent 80%
276
+ ),
277
+ radial-gradient(
278
+ 900px 200px at 80% 110%,
279
+ rgba(0, 0, 0, 0.05),
280
+ transparent 80%
281
+ );
282
  }
283
+
284
  /* Dark mode: richer gradient for Oblivion */
285
  :global([data-theme="dark"]) :global(.trackio.theme--oblivion .cell-bg) {
286
+ background: radial-gradient(
287
+ 1400px 260px at 20% -10%,
288
+ color-mix(in srgb, #ffffff 6.5%, transparent),
289
+ transparent 80%
290
+ ),
291
+ radial-gradient(
292
+ 1100px 240px at 80% 110%,
293
+ color-mix(in srgb, #ffffff 6%, transparent),
294
+ transparent 80%
295
+ ),
296
+ linear-gradient(
297
+ 180deg,
298
+ color-mix(in srgb, #ffffff 3.5%, transparent),
299
+ transparent 45%
300
+ );
301
  }
302
+
303
  :global(.trackio.theme--oblivion .cell-corners) {
304
  display: block !important;
305
  inset: 6px;
 
315
  opacity: 1;
316
  z-index: 3;
317
  }
318
+
319
  /* Dark mode: bright corners for Oblivion */
320
  :global([data-theme="dark"]) :global(.trackio.theme--oblivion .cell-corners) {
321
  background:
 
328
  linear-gradient(#ffffff, #ffffff) bottom right / 8px 1px no-repeat,
329
  linear-gradient(#ffffff, #ffffff) bottom right / 1px 8px no-repeat;
330
  }
331
+
332
  :global(.trackio .cell-header) {
333
+ padding: 0 0px 10px 10px;
334
  display: flex;
335
  align-items: center;
336
  justify-content: space-between;
337
  gap: 8px;
338
  }
339
+
340
  /* Oblivion theme: adjust header padding */
341
  :global(.trackio.theme--oblivion .cell-header) {
342
  padding: 5px 0px 18px 12px;
343
  }
344
+
345
  :global(.trackio .cell-title) {
346
  font-size: 13px;
347
  font-weight: 700;
348
  color: var(--trackio-text-primary);
349
  font-family: var(--trackio-font-family);
350
  }
351
+
352
  :global(.trackio .cell-body) {
353
  position: relative;
354
  width: 100%;
355
  overflow: hidden;
356
  }
357
+
358
  /* Oblivion theme overrides */
359
  :global(.trackio.theme--oblivion .cell) {
360
  border: none !important;
361
  background: transparent !important;
362
  }
363
+
364
  :global(.trackio.theme--classic .cell) {
365
  border: 1px solid var(--trackio-cell-border) !important;
366
  background: var(--trackio-cell-background) !important;
367
  border-radius: 10px !important;
368
  }
369
+
370
  :global(.trackio.theme--oblivion .cell-title) {
371
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
372
+ SFMono-Regular, Menlo, monospace !important;
373
  letter-spacing: 0.12em !important;
374
  text-transform: uppercase !important;
375
  font-weight: 800 !important;
 
377
  position: relative;
378
  padding-left: 14px;
379
  }
380
+
381
  /* Oblivion theme: add indicator dot before title */
382
  :global(.trackio.theme--oblivion .cell-title)::before {
383
  content: "";
 
394
  opacity: 0.6;
395
  }
396
 
 
397
  /* Ghost hover effect */
398
  :global(.trackio.hovering .ghost) {
399
  opacity: 0.2;
400
  transition: opacity 0.15s ease;
401
  }
402
+
403
  /* Specific ghost effect for raw lines when smoothing is active */
404
  :global(.trackio.hovering path.raw-line.ghost) {
405
  opacity: 0.1;
 
410
  grid-column: 1 / -1;
411
  }
412
 
413
+ /* Header buttons container */
414
+ .cell-header-buttons {
415
+ display: inline-flex;
416
+ align-items: center;
417
+ gap: 4px;
418
+ }
419
+
420
  /* Fullscreen button */
421
  .cell-fullscreen-btn {
422
  display: inline-flex;
 
432
  border-radius: 6px;
433
  transition: opacity 0.15s ease;
434
  }
435
+
436
  .cell-fullscreen-btn:hover {
437
  opacity: 1;
438
  }
439
+
440
  .cell-fullscreen-btn svg {
441
  width: 18px;
442
  height: 18px;
443
  fill: var(--trackio-chart-axis-text);
444
  }
445
+
446
+ /* Reset zoom button */
447
+ .cell-reset-btn {
448
+ display: inline-flex;
449
+ align-items: center;
450
+ justify-content: center;
451
+ height: 24px;
452
+ padding: 0 8px;
453
+ border: 1px solid var(--trackio-chart-axis-stroke);
454
+ background: transparent;
455
+ color: var(--trackio-chart-axis-text);
456
+ font-size: 11px;
457
+ font-weight: 500;
458
+ opacity: 0.7;
459
+ cursor: pointer;
460
+ border-radius: 4px;
461
+ transition: all 0.2s ease;
462
+ font-family: var(--trackio-font-family);
463
+ }
464
+
465
+ .cell-reset-btn:hover {
466
+ opacity: 1;
467
+ background: var(--trackio-chart-grid-stroke);
468
+ }
469
  </style>
app/src/components/trackio/components/FullscreenModal.svelte CHANGED
@@ -1,136 +1,136 @@
1
  <script>
2
- import { createEventDispatcher } from 'svelte';
3
- import ChartRenderer from '../renderers/ChartRendererRefactored.svelte';
4
- import ChartTooltip from '../renderers/ChartTooltip.svelte';
5
- import Legend from './Legend.svelte';
6
- import { formatAbbrev } from '../core/chart-utils.js';
7
-
8
  // Props
9
  export let visible = false;
10
- export let title = '';
11
  export let metricData = {};
12
  export let rawMetricData = {};
13
- export let colorForRun = (name) => '#999';
14
- export let variant = 'classic';
15
  export let logScaleX = false;
16
  export let smoothing = false;
17
  export let normalizeLoss = true;
18
- export let metricKey = '';
19
- export let titleText = '';
20
-
21
  // Navigation props
22
  export let currentIndex = 0;
23
  export let totalCharts = 1;
24
  export let onNavigate = null;
25
-
26
  const dispatch = createEventDispatcher();
27
-
28
  let modalElement;
29
-
30
  // Tooltip state (same as Cell.svelte)
31
  let tooltipVisible = false;
32
  let tooltipX = -9999;
33
  let tooltipY = -9999;
34
- let tooltipTitle = '';
35
- let tooltipSubtitle = '';
36
  let tooltipEntries = [];
37
-
38
  // Modal management
39
  $: if (visible && modalElement) {
40
  document.body.appendChild(modalElement);
41
-
42
  // Copy CSS variables from the trackio parent to ensure theme inheritance
43
- const trackioParent = document.querySelector('.trackio');
44
  if (trackioParent) {
45
  const computedStyle = getComputedStyle(trackioParent);
46
  const cssVars = [
47
- '--trackio-chart-axis-stroke',
48
- '--trackio-chart-axis-text',
49
- '--trackio-chart-grid-stroke',
50
- '--trackio-chart-grid-opacity',
51
- '--trackio-chart-grid-type',
52
- '--trackio-font-family',
53
- '--trackio-tooltip-background',
54
- '--trackio-tooltip-border',
55
- '--trackio-tooltip-shadow',
56
- '--trackio-text-primary',
57
- '--trackio-text-secondary'
58
  ];
59
-
60
- cssVars.forEach(varName => {
61
  const value = computedStyle.getPropertyValue(varName);
62
  if (value) {
63
  modalElement.style.setProperty(varName, value);
64
  }
65
  });
66
  }
67
-
68
  requestAnimationFrame(() => {
69
- modalElement.classList.add('show');
70
  });
71
  }
72
-
73
  function closeModal() {
74
  if (modalElement) {
75
- modalElement.classList.remove('show');
76
  setTimeout(() => {
77
  if (modalElement && modalElement.parentNode) {
78
  modalElement.parentNode.removeChild(modalElement);
79
  }
80
- dispatch('close');
81
  }, 300);
82
  }
83
  }
84
-
85
  function handleKeydown(e) {
86
- if (e.key === 'Escape') {
87
  closeModal();
88
- } else if (e.key === 'ArrowLeft') {
89
  navigatePrevious();
90
- } else if (e.key === 'ArrowRight') {
91
  navigateNext();
92
  }
93
  }
94
-
95
  function navigatePrevious() {
96
  if (onNavigate && totalCharts > 1) {
97
  const newIndex = currentIndex === 0 ? totalCharts - 1 : currentIndex - 1;
98
  onNavigate(newIndex);
99
  }
100
  }
101
-
102
  function navigateNext() {
103
  if (onNavigate && totalCharts > 1) {
104
  const newIndex = currentIndex === totalCharts - 1 ? 0 : currentIndex + 1;
105
  onNavigate(newIndex);
106
  }
107
  }
108
-
109
  function handleOverlayClick(e) {
110
  if (e.target === e.currentTarget) {
111
  closeModal();
112
  }
113
  }
114
-
115
  // Prepare legend data
116
  $: runs = Object.keys(metricData);
117
- $: legendData = runs.map(run => ({
118
  name: run,
119
- color: colorForRun(run)
120
  }));
121
-
122
  // Tooltip handling (same logic as Cell.svelte)
123
  function handleChartHover(data) {
124
  const { step, entries, position } = data;
125
-
126
  if (entries.length) {
127
  // Use global mouse coordinates for tooltip positioning
128
  const modalRect = modalElement.getBoundingClientRect();
129
-
130
  // Position tooltip near global cursor with small offset
131
  const relativeX = (position.globalX || position.x) - modalRect.left + 15;
132
  const relativeY = (position.globalY || position.y) - modalRect.top + 15;
133
-
134
  tooltipVisible = true;
135
  tooltipX = Math.round(relativeX);
136
  tooltipY = Math.round(relativeY);
@@ -139,53 +139,61 @@
139
  tooltipEntries = entries;
140
  }
141
  }
142
-
143
  function handleChartLeave() {
144
  tooltipVisible = false;
145
  tooltipX = -9999;
146
  tooltipY = -9999;
147
  }
148
-
149
  // Ghost legend functionality
150
  function handleLegendHover(idx) {
151
  legendData.forEach((otherItem, otherIdx) => {
152
  if (otherIdx !== idx) {
153
- const legendItems = modalElement?.querySelectorAll('.item');
154
  if (legendItems && legendItems[otherIdx]) {
155
- legendItems[otherIdx].classList.add('ghost');
156
  }
157
-
158
- const chartElements = modalElement?.querySelectorAll(`[data-run="${otherItem.name}"]`);
159
- chartElements?.forEach(el => el.classList.add('ghost'));
 
 
160
  }
161
  });
162
-
163
  // Add hovering class to trigger the ghost styles
164
- const modalChart = modalElement?.querySelector('.trackio-modal-chart-content');
165
- modalChart?.classList.add('hovering');
 
 
166
  }
167
-
168
  function handleLegendLeave() {
169
- const legendItems = modalElement?.querySelectorAll('.item');
170
- legendItems?.forEach(item => item.classList.remove('ghost'));
171
-
172
- const chartElements = modalElement?.querySelectorAll('[data-run]');
173
- chartElements?.forEach(el => el.classList.remove('ghost'));
174
-
175
  // Remove hovering class
176
- const modalChart = modalElement?.querySelector('.trackio-modal-chart-content');
177
- modalChart?.classList.remove('hovering');
 
 
178
  }
179
  </script>
180
 
181
  <!-- Modal overlay -->
182
  {#if visible}
183
- <div
184
  bind:this={modalElement}
185
- class="trackio-modal-overlay trackio {variant === 'oblivion' ? 'theme--oblivion' : 'theme--classic'}"
 
 
186
  on:click={handleOverlayClick}
187
  on:keydown={handleKeydown}
188
- role="dialog"
189
  aria-modal="true"
190
  tabindex="-1"
191
  >
@@ -195,43 +203,43 @@
195
  <div class="trackio-modal-header-left">
196
  <h3>{title}</h3>
197
  </div>
198
-
199
  <div class="trackio-modal-header-right">
200
  <!-- Navigation controls grouped with counter -->
201
  <div class="trackio-modal-nav-counter-group">
202
  {#if totalCharts > 1}
203
- <button
204
  class="trackio-modal-nav-inline trackio-modal-nav-inline-left"
205
  on:click={navigatePrevious}
206
  title="Previous chart (←)"
207
  aria-label="Previous chart"
208
  >
209
  <svg viewBox="0 0 24 24" fill="currentColor">
210
- <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
211
  </svg>
212
  </button>
213
  {/if}
214
-
215
  <div class="trackio-modal-counter">
216
  {currentIndex + 1}/{totalCharts}
217
  </div>
218
-
219
  {#if totalCharts > 1}
220
- <button
221
  class="trackio-modal-nav-inline trackio-modal-nav-inline-right"
222
  on:click={navigateNext}
223
  title="Next chart (→)"
224
  aria-label="Next chart"
225
  >
226
  <svg viewBox="0 0 24 24" fill="currentColor">
227
- <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
228
  </svg>
229
  </button>
230
  {/if}
231
  </div>
232
-
233
- <button
234
- class="trackio-modal-close"
235
  on:click={closeModal}
236
  title="Close"
237
  aria-label="Close modal"
@@ -240,7 +248,7 @@
240
  </button>
241
  </div>
242
  </div>
243
-
244
  <!-- Content -->
245
  <div class="trackio-modal-content">
246
  <!-- Legend -->
@@ -249,14 +257,21 @@
249
  <Legend
250
  items={legendData}
251
  alignment="left"
252
- on:legend-hover={(e) => handleLegendHover(legendData.findIndex(item => item.name === e.detail.name))}
 
 
 
253
  on:legend-leave={handleLegendLeave}
254
  />
255
  </div>
256
  {/if}
257
-
258
  <!-- Chart -->
259
- <div class="trackio-modal-chart-content trackio {variant === 'oblivion' ? 'theme--oblivion' : 'theme--classic'}">
 
 
 
 
260
  <ChartRenderer
261
  {metricData}
262
  {rawMetricData}
@@ -275,7 +290,7 @@
275
  </div>
276
  </div>
277
  </div>
278
-
279
  <!-- Tooltip (same as Cell.svelte but with higher z-index) -->
280
  <ChartTooltip
281
  visible={tooltipVisible}
@@ -304,32 +319,33 @@
304
  pointer-events: none;
305
  transition: opacity 0.3s ease;
306
  }
307
-
308
  /* Light mode overlay */
309
  :global([data-theme="light"]) :global(.trackio-modal-overlay) {
310
  background: rgba(255, 255, 255, 0.85);
311
  }
312
-
313
  /* Dark mode overlay */
314
  :global([data-theme="dark"]) :global(.trackio-modal-overlay) {
315
  background: rgba(0, 0, 0, 0.8);
316
  }
317
-
318
  /* Oblivion theme overlay - light mode */
319
- :global([data-theme="light"]) :global(.trackio-modal-overlay.theme--oblivion) {
 
320
  background: rgba(240, 245, 255, 0.9);
321
  }
322
-
323
  /* Oblivion theme overlay - dark mode */
324
  :global([data-theme="dark"]) :global(.trackio-modal-overlay.theme--oblivion) {
325
  background: rgba(15, 20, 30, 0.85);
326
  }
327
-
328
  :global(.trackio-modal-overlay.show) {
329
  opacity: 1;
330
  pointer-events: auto;
331
  }
332
-
333
  :global(.trackio-modal) {
334
  position: relative;
335
  width: min(95vw, 1200px);
@@ -337,12 +353,13 @@
337
 
338
  background: var(--surface-bg);
339
  border-radius: 12px;
 
340
  overflow: hidden;
341
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
342
  display: flex;
343
  flex-direction: column;
344
  }
345
-
346
  :global(.trackio-modal-header) {
347
  display: flex;
348
  justify-content: space-between;
@@ -350,38 +367,38 @@
350
  padding: 16px 20px 0px 20px;
351
  background: var(--surface-bg, white);
352
  }
353
-
354
  :global(.trackio-modal-header-left) {
355
  display: flex;
356
  align-items: center;
357
  flex: 1;
358
  }
359
-
360
  :global(.trackio-modal-header-right) {
361
  display: flex;
362
  align-items: center;
363
  gap: 12px;
364
  }
365
-
366
  :global(.trackio-modal-nav-counter-group) {
367
  display: flex;
368
  align-items: center;
369
  gap: 4px;
370
  }
371
-
372
  :global(.trackio-modal-counter) {
373
  font-size: 10px;
374
  color: var(--muted-color);
375
  font-family: var(--trackio-font-family);
376
  font-weight: 500;
377
- background: none!important;
378
- border: none!important;
379
  opacity: 0.6;
380
  padding: 2px 6px;
381
  border-radius: 4px;
382
  line-height: 1;
383
  }
384
-
385
  :global(.trackio-modal-header h3) {
386
  margin: 0;
387
  font-size: 16px;
@@ -389,7 +406,7 @@
389
  color: var(--text-color, rgba(0, 0, 0, 0.9));
390
  flex: 1;
391
  }
392
-
393
  :global(.trackio-modal-close) {
394
  width: 32px;
395
  height: 32px;
@@ -404,11 +421,11 @@
404
  font-size: 20px;
405
  transition: background-color 0.15s ease;
406
  }
407
-
408
  :global(.trackio-modal-close:hover) {
409
  background: var(--border-color, rgba(0, 0, 0, 0.1));
410
  }
411
-
412
  /* Inline navigation arrows in header */
413
  :global(.trackio-modal-nav-inline) {
414
  width: 24px;
@@ -425,23 +442,23 @@
425
  transition: all 0.15s ease;
426
  flex-shrink: 0;
427
  }
428
-
429
  :global(.trackio-modal-nav-inline:hover) {
430
  background: var(--border-color, rgba(0, 0, 0, 0.1));
431
  color: var(--text-color, rgba(0, 0, 0, 0.9));
432
  transform: scale(1.1);
433
  }
434
-
435
  :global(.trackio-modal-nav-inline:active) {
436
  transform: scale(0.9);
437
  }
438
-
439
  :global(.trackio-modal-nav-inline svg) {
440
  width: 14px;
441
  height: 14px;
442
  fill: currentColor;
443
  }
444
-
445
  :global(.trackio-modal-content) {
446
  flex: 1;
447
  padding: 20px;
@@ -450,39 +467,39 @@
450
  flex-direction: column;
451
  gap: 16px;
452
  }
453
-
454
  :global(.trackio-modal-legend) {
455
  display: flex;
456
  justify-content: flex-start;
457
  align-items: center;
458
  }
459
-
460
  :global(.trackio-modal-chart-content) {
461
  flex: 1;
462
  position: relative;
463
  min-height: 0;
464
  }
465
-
466
  /* Ghost hover effect */
467
  :global(.trackio-modal .ghost) {
468
  opacity: 0.2;
469
  transition: opacity 0.15s ease;
470
  }
471
-
472
  /* Specific ghost effect for raw lines when smoothing is active */
473
  :global(.trackio-modal.hovering path.raw-line.ghost) {
474
  opacity: 0.1;
475
  }
476
-
477
  /* =========================
478
  OBLIVION THEME STYLES
479
  ========================= */
480
-
481
  /* Oblivion modal overlay */
482
  :global(.trackio-modal-overlay.theme--oblivion) {
483
  background: rgba(15, 17, 21, 0.9);
484
  }
485
-
486
  /* Oblivion modal box - styled like a cell with corners */
487
  :global(.theme--oblivion .trackio-modal) {
488
  position: relative;
@@ -492,7 +509,7 @@
492
  backdrop-filter: blur(8px);
493
  backdrop-filter: saturate(1.1) blur(15px);
494
  }
495
-
496
  /* Modal background layer (like cell-bg) */
497
  :global(.theme--oblivion .trackio-modal)::before {
498
  content: "";
@@ -500,21 +517,36 @@
500
  pointer-events: none;
501
  z-index: 1;
502
  border-radius: 4px;
503
- background:
504
- radial-gradient(1200px 200px at 20% -10%, rgba(0,0,0,.05), transparent 80%),
505
- radial-gradient(900px 200px at 80% 110%, rgba(0,0,0,.05), transparent 80%);
 
 
 
 
 
 
 
506
  backdrop-filter: blur(10px);
507
  }
508
-
509
  /* Dark mode oblivion modal */
510
- :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal)::before {
511
- background:
512
- radial-gradient(1400px 260px at 20% -10%, color-mix(in srgb, #ffffff 6.5%, transparent), transparent 80%),
513
- radial-gradient(1100px 240px at 80% 110%, color-mix(in srgb, #ffffff 6%, transparent), transparent 80%);
514
- /* linear-gradient(180deg, color-mix(in srgb, #ffffff 3.5%, transparent), transparent 45%); */
515
- backdrop-filter: blur(10px);
516
- }
517
-
 
 
 
 
 
 
 
 
518
  /* Dark mode: bright corners */
519
  :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal)::after {
520
  background:
@@ -527,49 +559,52 @@
527
  linear-gradient(#ffffff, #ffffff) bottom right / 8px 1px no-repeat,
528
  linear-gradient(#ffffff, #ffffff) bottom right / 1px 8px no-repeat;
529
  }
530
-
531
  /* Modal content above pseudo-elements */
532
  :global(.theme--oblivion .trackio-modal-header),
533
  :global(.theme--oblivion .trackio-modal-content) {
534
  position: relative;
535
  z-index: 5;
536
  }
537
-
538
  /* Oblivion modal header */
539
  :global(.theme--oblivion .trackio-modal-header) {
540
  background: transparent;
541
  }
542
-
543
  :global(.theme--oblivion .trackio-modal-header h3) {
544
  color: var(--trackio-oblivion-primary, #2a2a2a);
545
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
546
  font-weight: 800;
547
  letter-spacing: 0.12em;
548
  text-transform: uppercase;
549
  font-size: 14px;
550
  }
551
-
552
  :global(.theme--oblivion .trackio-modal-counter) {
553
  background: var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
554
  color: var(--trackio-oblivion-primary, #2a2a2a);
555
  border: 1px solid var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
556
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
557
  font-weight: 600;
558
  letter-spacing: 0.08em;
559
  }
560
-
561
  :global(.theme--oblivion .trackio-modal-close) {
562
  color: var(--trackio-oblivion-primary, #2a2a2a);
563
  background: transparent;
564
  border: 1px solid transparent;
565
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
566
  }
567
-
568
  :global(.theme--oblivion .trackio-modal-close:hover) {
569
  background: var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
570
  border: 1px solid var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
571
  }
572
-
573
  /* Oblivion inline navigation arrows */
574
  :global(.theme--oblivion .trackio-modal-nav-inline) {
575
  background: transparent;
@@ -577,41 +612,46 @@
577
  color: var(--trackio-oblivion-primary, #2a2a2a);
578
  border-radius: 4px;
579
  }
580
-
581
  :global(.theme--oblivion .trackio-modal-nav-inline:hover) {
582
  background: var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
583
  transform: scale(1.1);
584
  }
585
-
586
  /* Dark mode overrides for modal content */
587
-
588
- :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal-header h3) {
 
589
  color: #ffffff;
590
  }
591
-
592
- :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal-counter) {
 
593
  background: color-mix(in srgb, #ffffff 25%, transparent);
594
  color: #ffffff;
595
  border: 1px solid color-mix(in srgb, #ffffff 25%, transparent);
596
  }
597
-
598
  :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal-close) {
599
  color: #ffffff;
600
  }
601
-
602
- :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal-close:hover) {
 
603
  background: color-mix(in srgb, #ffffff 25%, transparent);
604
  border: 1px solid color-mix(in srgb, #ffffff 25%, transparent);
605
  }
606
-
607
  /* Dark mode inline navigation arrows */
608
- :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal-nav-inline) {
 
609
  background: transparent;
610
  border: none;
611
  color: #ffffff;
612
  }
613
-
614
- :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal-nav-inline:hover) {
 
615
  background: color-mix(in srgb, #ffffff 25%, transparent);
616
  transform: scale(1.1);
617
  }
 
1
  <script>
2
+ import { createEventDispatcher } from "svelte";
3
+ import ChartRenderer from "../renderers/ChartRendererRefactored.svelte";
4
+ import ChartTooltip from "../renderers/ChartTooltip.svelte";
5
+ import Legend from "./Legend.svelte";
6
+ import { formatAbbrev } from "../core/chart-utils.js";
7
+
8
  // Props
9
  export let visible = false;
10
+ export let title = "";
11
  export let metricData = {};
12
  export let rawMetricData = {};
13
+ export let colorForRun = (name) => "#999";
14
+ export let variant = "classic";
15
  export let logScaleX = false;
16
  export let smoothing = false;
17
  export let normalizeLoss = true;
18
+ export let metricKey = "";
19
+ export let titleText = "";
20
+
21
  // Navigation props
22
  export let currentIndex = 0;
23
  export let totalCharts = 1;
24
  export let onNavigate = null;
25
+
26
  const dispatch = createEventDispatcher();
27
+
28
  let modalElement;
29
+
30
  // Tooltip state (same as Cell.svelte)
31
  let tooltipVisible = false;
32
  let tooltipX = -9999;
33
  let tooltipY = -9999;
34
+ let tooltipTitle = "";
35
+ let tooltipSubtitle = "";
36
  let tooltipEntries = [];
37
+
38
  // Modal management
39
  $: if (visible && modalElement) {
40
  document.body.appendChild(modalElement);
41
+
42
  // Copy CSS variables from the trackio parent to ensure theme inheritance
43
+ const trackioParent = document.querySelector(".trackio");
44
  if (trackioParent) {
45
  const computedStyle = getComputedStyle(trackioParent);
46
  const cssVars = [
47
+ "--trackio-chart-axis-stroke",
48
+ "--trackio-chart-axis-text",
49
+ "--trackio-chart-grid-stroke",
50
+ "--trackio-chart-grid-opacity",
51
+ "--trackio-chart-grid-type",
52
+ "--trackio-font-family",
53
+ "--trackio-tooltip-background",
54
+ "--trackio-tooltip-border",
55
+ "--trackio-tooltip-shadow",
56
+ "--trackio-text-primary",
57
+ "--trackio-text-secondary",
58
  ];
59
+
60
+ cssVars.forEach((varName) => {
61
  const value = computedStyle.getPropertyValue(varName);
62
  if (value) {
63
  modalElement.style.setProperty(varName, value);
64
  }
65
  });
66
  }
67
+
68
  requestAnimationFrame(() => {
69
+ modalElement.classList.add("show");
70
  });
71
  }
72
+
73
  function closeModal() {
74
  if (modalElement) {
75
+ modalElement.classList.remove("show");
76
  setTimeout(() => {
77
  if (modalElement && modalElement.parentNode) {
78
  modalElement.parentNode.removeChild(modalElement);
79
  }
80
+ dispatch("close");
81
  }, 300);
82
  }
83
  }
84
+
85
  function handleKeydown(e) {
86
+ if (e.key === "Escape") {
87
  closeModal();
88
+ } else if (e.key === "ArrowLeft") {
89
  navigatePrevious();
90
+ } else if (e.key === "ArrowRight") {
91
  navigateNext();
92
  }
93
  }
94
+
95
  function navigatePrevious() {
96
  if (onNavigate && totalCharts > 1) {
97
  const newIndex = currentIndex === 0 ? totalCharts - 1 : currentIndex - 1;
98
  onNavigate(newIndex);
99
  }
100
  }
101
+
102
  function navigateNext() {
103
  if (onNavigate && totalCharts > 1) {
104
  const newIndex = currentIndex === totalCharts - 1 ? 0 : currentIndex + 1;
105
  onNavigate(newIndex);
106
  }
107
  }
108
+
109
  function handleOverlayClick(e) {
110
  if (e.target === e.currentTarget) {
111
  closeModal();
112
  }
113
  }
114
+
115
  // Prepare legend data
116
  $: runs = Object.keys(metricData);
117
+ $: legendData = runs.map((run) => ({
118
  name: run,
119
+ color: colorForRun(run),
120
  }));
121
+
122
  // Tooltip handling (same logic as Cell.svelte)
123
  function handleChartHover(data) {
124
  const { step, entries, position } = data;
125
+
126
  if (entries.length) {
127
  // Use global mouse coordinates for tooltip positioning
128
  const modalRect = modalElement.getBoundingClientRect();
129
+
130
  // Position tooltip near global cursor with small offset
131
  const relativeX = (position.globalX || position.x) - modalRect.left + 15;
132
  const relativeY = (position.globalY || position.y) - modalRect.top + 15;
133
+
134
  tooltipVisible = true;
135
  tooltipX = Math.round(relativeX);
136
  tooltipY = Math.round(relativeY);
 
139
  tooltipEntries = entries;
140
  }
141
  }
142
+
143
  function handleChartLeave() {
144
  tooltipVisible = false;
145
  tooltipX = -9999;
146
  tooltipY = -9999;
147
  }
148
+
149
  // Ghost legend functionality
150
  function handleLegendHover(idx) {
151
  legendData.forEach((otherItem, otherIdx) => {
152
  if (otherIdx !== idx) {
153
+ const legendItems = modalElement?.querySelectorAll(".item");
154
  if (legendItems && legendItems[otherIdx]) {
155
+ legendItems[otherIdx].classList.add("ghost");
156
  }
157
+
158
+ const chartElements = modalElement?.querySelectorAll(
159
+ `[data-run="${otherItem.name}"]`,
160
+ );
161
+ chartElements?.forEach((el) => el.classList.add("ghost"));
162
  }
163
  });
164
+
165
  // Add hovering class to trigger the ghost styles
166
+ const modalChart = modalElement?.querySelector(
167
+ ".trackio-modal-chart-content",
168
+ );
169
+ modalChart?.classList.add("hovering");
170
  }
171
+
172
  function handleLegendLeave() {
173
+ const legendItems = modalElement?.querySelectorAll(".item");
174
+ legendItems?.forEach((item) => item.classList.remove("ghost"));
175
+
176
+ const chartElements = modalElement?.querySelectorAll("[data-run]");
177
+ chartElements?.forEach((el) => el.classList.remove("ghost"));
178
+
179
  // Remove hovering class
180
+ const modalChart = modalElement?.querySelector(
181
+ ".trackio-modal-chart-content",
182
+ );
183
+ modalChart?.classList.remove("hovering");
184
  }
185
  </script>
186
 
187
  <!-- Modal overlay -->
188
  {#if visible}
189
+ <div
190
  bind:this={modalElement}
191
+ class="trackio-modal-overlay trackio {variant === 'oblivion'
192
+ ? 'theme--oblivion'
193
+ : 'theme--classic'}"
194
  on:click={handleOverlayClick}
195
  on:keydown={handleKeydown}
196
+ role="dialog"
197
  aria-modal="true"
198
  tabindex="-1"
199
  >
 
203
  <div class="trackio-modal-header-left">
204
  <h3>{title}</h3>
205
  </div>
206
+
207
  <div class="trackio-modal-header-right">
208
  <!-- Navigation controls grouped with counter -->
209
  <div class="trackio-modal-nav-counter-group">
210
  {#if totalCharts > 1}
211
+ <button
212
  class="trackio-modal-nav-inline trackio-modal-nav-inline-left"
213
  on:click={navigatePrevious}
214
  title="Previous chart (←)"
215
  aria-label="Previous chart"
216
  >
217
  <svg viewBox="0 0 24 24" fill="currentColor">
218
+ <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
219
  </svg>
220
  </button>
221
  {/if}
222
+
223
  <div class="trackio-modal-counter">
224
  {currentIndex + 1}/{totalCharts}
225
  </div>
226
+
227
  {#if totalCharts > 1}
228
+ <button
229
  class="trackio-modal-nav-inline trackio-modal-nav-inline-right"
230
  on:click={navigateNext}
231
  title="Next chart (→)"
232
  aria-label="Next chart"
233
  >
234
  <svg viewBox="0 0 24 24" fill="currentColor">
235
+ <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
236
  </svg>
237
  </button>
238
  {/if}
239
  </div>
240
+
241
+ <button
242
+ class="trackio-modal-close"
243
  on:click={closeModal}
244
  title="Close"
245
  aria-label="Close modal"
 
248
  </button>
249
  </div>
250
  </div>
251
+
252
  <!-- Content -->
253
  <div class="trackio-modal-content">
254
  <!-- Legend -->
 
257
  <Legend
258
  items={legendData}
259
  alignment="left"
260
+ on:legend-hover={(e) =>
261
+ handleLegendHover(
262
+ legendData.findIndex((item) => item.name === e.detail.name),
263
+ )}
264
  on:legend-leave={handleLegendLeave}
265
  />
266
  </div>
267
  {/if}
268
+
269
  <!-- Chart -->
270
+ <div
271
+ class="trackio-modal-chart-content trackio {variant === 'oblivion'
272
+ ? 'theme--oblivion'
273
+ : 'theme--classic'}"
274
+ >
275
  <ChartRenderer
276
  {metricData}
277
  {rawMetricData}
 
290
  </div>
291
  </div>
292
  </div>
293
+
294
  <!-- Tooltip (same as Cell.svelte but with higher z-index) -->
295
  <ChartTooltip
296
  visible={tooltipVisible}
 
319
  pointer-events: none;
320
  transition: opacity 0.3s ease;
321
  }
322
+
323
  /* Light mode overlay */
324
  :global([data-theme="light"]) :global(.trackio-modal-overlay) {
325
  background: rgba(255, 255, 255, 0.85);
326
  }
327
+
328
  /* Dark mode overlay */
329
  :global([data-theme="dark"]) :global(.trackio-modal-overlay) {
330
  background: rgba(0, 0, 0, 0.8);
331
  }
332
+
333
  /* Oblivion theme overlay - light mode */
334
+ :global([data-theme="light"])
335
+ :global(.trackio-modal-overlay.theme--oblivion) {
336
  background: rgba(240, 245, 255, 0.9);
337
  }
338
+
339
  /* Oblivion theme overlay - dark mode */
340
  :global([data-theme="dark"]) :global(.trackio-modal-overlay.theme--oblivion) {
341
  background: rgba(15, 20, 30, 0.85);
342
  }
343
+
344
  :global(.trackio-modal-overlay.show) {
345
  opacity: 1;
346
  pointer-events: auto;
347
  }
348
+
349
  :global(.trackio-modal) {
350
  position: relative;
351
  width: min(95vw, 1200px);
 
353
 
354
  background: var(--surface-bg);
355
  border-radius: 12px;
356
+ border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
357
  overflow: hidden;
358
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
359
  display: flex;
360
  flex-direction: column;
361
  }
362
+
363
  :global(.trackio-modal-header) {
364
  display: flex;
365
  justify-content: space-between;
 
367
  padding: 16px 20px 0px 20px;
368
  background: var(--surface-bg, white);
369
  }
370
+
371
  :global(.trackio-modal-header-left) {
372
  display: flex;
373
  align-items: center;
374
  flex: 1;
375
  }
376
+
377
  :global(.trackio-modal-header-right) {
378
  display: flex;
379
  align-items: center;
380
  gap: 12px;
381
  }
382
+
383
  :global(.trackio-modal-nav-counter-group) {
384
  display: flex;
385
  align-items: center;
386
  gap: 4px;
387
  }
388
+
389
  :global(.trackio-modal-counter) {
390
  font-size: 10px;
391
  color: var(--muted-color);
392
  font-family: var(--trackio-font-family);
393
  font-weight: 500;
394
+ background: none !important;
395
+ border: none !important;
396
  opacity: 0.6;
397
  padding: 2px 6px;
398
  border-radius: 4px;
399
  line-height: 1;
400
  }
401
+
402
  :global(.trackio-modal-header h3) {
403
  margin: 0;
404
  font-size: 16px;
 
406
  color: var(--text-color, rgba(0, 0, 0, 0.9));
407
  flex: 1;
408
  }
409
+
410
  :global(.trackio-modal-close) {
411
  width: 32px;
412
  height: 32px;
 
421
  font-size: 20px;
422
  transition: background-color 0.15s ease;
423
  }
424
+
425
  :global(.trackio-modal-close:hover) {
426
  background: var(--border-color, rgba(0, 0, 0, 0.1));
427
  }
428
+
429
  /* Inline navigation arrows in header */
430
  :global(.trackio-modal-nav-inline) {
431
  width: 24px;
 
442
  transition: all 0.15s ease;
443
  flex-shrink: 0;
444
  }
445
+
446
  :global(.trackio-modal-nav-inline:hover) {
447
  background: var(--border-color, rgba(0, 0, 0, 0.1));
448
  color: var(--text-color, rgba(0, 0, 0, 0.9));
449
  transform: scale(1.1);
450
  }
451
+
452
  :global(.trackio-modal-nav-inline:active) {
453
  transform: scale(0.9);
454
  }
455
+
456
  :global(.trackio-modal-nav-inline svg) {
457
  width: 14px;
458
  height: 14px;
459
  fill: currentColor;
460
  }
461
+
462
  :global(.trackio-modal-content) {
463
  flex: 1;
464
  padding: 20px;
 
467
  flex-direction: column;
468
  gap: 16px;
469
  }
470
+
471
  :global(.trackio-modal-legend) {
472
  display: flex;
473
  justify-content: flex-start;
474
  align-items: center;
475
  }
476
+
477
  :global(.trackio-modal-chart-content) {
478
  flex: 1;
479
  position: relative;
480
  min-height: 0;
481
  }
482
+
483
  /* Ghost hover effect */
484
  :global(.trackio-modal .ghost) {
485
  opacity: 0.2;
486
  transition: opacity 0.15s ease;
487
  }
488
+
489
  /* Specific ghost effect for raw lines when smoothing is active */
490
  :global(.trackio-modal.hovering path.raw-line.ghost) {
491
  opacity: 0.1;
492
  }
493
+
494
  /* =========================
495
  OBLIVION THEME STYLES
496
  ========================= */
497
+
498
  /* Oblivion modal overlay */
499
  :global(.trackio-modal-overlay.theme--oblivion) {
500
  background: rgba(15, 17, 21, 0.9);
501
  }
502
+
503
  /* Oblivion modal box - styled like a cell with corners */
504
  :global(.theme--oblivion .trackio-modal) {
505
  position: relative;
 
509
  backdrop-filter: blur(8px);
510
  backdrop-filter: saturate(1.1) blur(15px);
511
  }
512
+
513
  /* Modal background layer (like cell-bg) */
514
  :global(.theme--oblivion .trackio-modal)::before {
515
  content: "";
 
517
  pointer-events: none;
518
  z-index: 1;
519
  border-radius: 4px;
520
+ background: radial-gradient(
521
+ 1200px 200px at 20% -10%,
522
+ rgba(0, 0, 0, 0.05),
523
+ transparent 80%
524
+ ),
525
+ radial-gradient(
526
+ 900px 200px at 80% 110%,
527
+ rgba(0, 0, 0, 0.05),
528
+ transparent 80%
529
+ );
530
  backdrop-filter: blur(10px);
531
  }
532
+
533
  /* Dark mode oblivion modal */
534
+ :global([data-theme="dark"])
535
+ :global(.theme--oblivion .trackio-modal)::before {
536
+ background: radial-gradient(
537
+ 1400px 260px at 20% -10%,
538
+ color-mix(in srgb, #ffffff 6.5%, transparent),
539
+ transparent 80%
540
+ ),
541
+ radial-gradient(
542
+ 1100px 240px at 80% 110%,
543
+ color-mix(in srgb, #ffffff 6%, transparent),
544
+ transparent 80%
545
+ );
546
+ /* linear-gradient(180deg, color-mix(in srgb, #ffffff 3.5%, transparent), transparent 45%); */
547
+ backdrop-filter: blur(10px);
548
+ }
549
+
550
  /* Dark mode: bright corners */
551
  :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal)::after {
552
  background:
 
559
  linear-gradient(#ffffff, #ffffff) bottom right / 8px 1px no-repeat,
560
  linear-gradient(#ffffff, #ffffff) bottom right / 1px 8px no-repeat;
561
  }
562
+
563
  /* Modal content above pseudo-elements */
564
  :global(.theme--oblivion .trackio-modal-header),
565
  :global(.theme--oblivion .trackio-modal-content) {
566
  position: relative;
567
  z-index: 5;
568
  }
569
+
570
  /* Oblivion modal header */
571
  :global(.theme--oblivion .trackio-modal-header) {
572
  background: transparent;
573
  }
574
+
575
  :global(.theme--oblivion .trackio-modal-header h3) {
576
  color: var(--trackio-oblivion-primary, #2a2a2a);
577
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
578
+ SFMono-Regular, Menlo, monospace !important;
579
  font-weight: 800;
580
  letter-spacing: 0.12em;
581
  text-transform: uppercase;
582
  font-size: 14px;
583
  }
584
+
585
  :global(.theme--oblivion .trackio-modal-counter) {
586
  background: var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
587
  color: var(--trackio-oblivion-primary, #2a2a2a);
588
  border: 1px solid var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
589
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
590
+ SFMono-Regular, Menlo, monospace !important;
591
  font-weight: 600;
592
  letter-spacing: 0.08em;
593
  }
594
+
595
  :global(.theme--oblivion .trackio-modal-close) {
596
  color: var(--trackio-oblivion-primary, #2a2a2a);
597
  background: transparent;
598
  border: 1px solid transparent;
599
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
600
+ SFMono-Regular, Menlo, monospace !important;
601
  }
602
+
603
  :global(.theme--oblivion .trackio-modal-close:hover) {
604
  background: var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
605
  border: 1px solid var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
606
  }
607
+
608
  /* Oblivion inline navigation arrows */
609
  :global(.theme--oblivion .trackio-modal-nav-inline) {
610
  background: transparent;
 
612
  color: var(--trackio-oblivion-primary, #2a2a2a);
613
  border-radius: 4px;
614
  }
615
+
616
  :global(.theme--oblivion .trackio-modal-nav-inline:hover) {
617
  background: var(--trackio-oblivion-dim, rgba(42, 42, 42, 0.3));
618
  transform: scale(1.1);
619
  }
620
+
621
  /* Dark mode overrides for modal content */
622
+
623
+ :global([data-theme="dark"])
624
+ :global(.theme--oblivion .trackio-modal-header h3) {
625
  color: #ffffff;
626
  }
627
+
628
+ :global([data-theme="dark"])
629
+ :global(.theme--oblivion .trackio-modal-counter) {
630
  background: color-mix(in srgb, #ffffff 25%, transparent);
631
  color: #ffffff;
632
  border: 1px solid color-mix(in srgb, #ffffff 25%, transparent);
633
  }
634
+
635
  :global([data-theme="dark"]) :global(.theme--oblivion .trackio-modal-close) {
636
  color: #ffffff;
637
  }
638
+
639
+ :global([data-theme="dark"])
640
+ :global(.theme--oblivion .trackio-modal-close:hover) {
641
  background: color-mix(in srgb, #ffffff 25%, transparent);
642
  border: 1px solid color-mix(in srgb, #ffffff 25%, transparent);
643
  }
644
+
645
  /* Dark mode inline navigation arrows */
646
+ :global([data-theme="dark"])
647
+ :global(.theme--oblivion .trackio-modal-nav-inline) {
648
  background: transparent;
649
  border: none;
650
  color: #ffffff;
651
  }
652
+
653
+ :global([data-theme="dark"])
654
+ :global(.theme--oblivion .trackio-modal-nav-inline:hover) {
655
  background: color-mix(in srgb, #ffffff 25%, transparent);
656
  transform: scale(1.1);
657
  }
app/src/components/trackio/core/adaptive-sampler.js CHANGED
@@ -32,10 +32,10 @@ export class AdaptiveSampler {
32
  }
33
 
34
  const actualStrategy = strategy || this.options.adaptiveStrategy;
35
-
36
  if (!this.needsSampling(data.length)) {
37
- return {
38
- data: data.slice(),
39
  sampledIndices: data.map((_, i) => i),
40
  compressionRatio: 1,
41
  strategy: 'none'
@@ -88,29 +88,29 @@ export class AdaptiveSampler {
88
  }
89
 
90
  /**
91
- * Smart sampling - préserve les features importantes
92
- * Inspiré de l'algorithme de Douglas-Peucker adapté pour les time series
93
  */
94
  smartSampling(data) {
95
  const targetPoints = this.options.targetPoints;
96
  const features = this.detectFeatures(data);
97
-
98
- // Étape 1: Points critiques (début, fin, features importantes)
99
  const criticalPoints = new Set([0, data.length - 1]);
100
-
101
- // Ajouter les features détectés
102
  features.peaks.forEach(idx => criticalPoints.add(idx));
103
  features.valleys.forEach(idx => criticalPoints.add(idx));
104
  features.inflectionPoints.forEach(idx => criticalPoints.add(idx));
105
 
106
- // Étape 2: Répartition logarithmique pour préserver la densité
107
  const remaining = targetPoints - criticalPoints.size;
108
  if (remaining > 0) {
109
  const logSamples = this.generateLogSpacing(data.length, remaining);
110
  logSamples.forEach(idx => criticalPoints.add(idx));
111
  }
112
 
113
- // Étape 3: Densité adaptive dans les zones de changement
114
  if (criticalPoints.size < targetPoints) {
115
  const variationSamples = this.sampleByVariation(data, targetPoints - criticalPoints.size);
116
  variationSamples.forEach(idx => criticalPoints.add(idx));
@@ -129,30 +129,30 @@ export class AdaptiveSampler {
129
  }
130
 
131
  /**
132
- * Level-of-Detail sampling - adaptatif selon le zoom/contexte
133
  */
134
  lodSampling(data, viewportStart = 0, viewportEnd = 1, zoomLevel = 1) {
135
  const viewStart = Math.floor(viewportStart * data.length);
136
  const viewEnd = Math.ceil(viewportEnd * data.length);
137
  const viewData = data.slice(viewStart, viewEnd);
138
-
139
- // Plus de détails dans la zone visible
140
  const visibleTargetPoints = Math.floor(this.options.targetPoints * 0.7);
141
  const contextTargetPoints = this.options.targetPoints - visibleTargetPoints;
142
-
143
- // Sampling dense dans la zone visible
144
  const visibleSample = this.smartSampling(viewData);
145
-
146
- // Sampling sparse dans le contexte
147
  const beforeContext = data.slice(0, viewStart);
148
  const afterContext = data.slice(viewEnd);
149
-
150
- const beforeSample = beforeContext.length > 0 ?
151
  this.uniformSampling(beforeContext) : { data: [], sampledIndices: [] };
152
- const afterSample = afterContext.length > 0 ?
153
  this.uniformSampling(afterContext) : { data: [], sampledIndices: [] };
154
 
155
- // Combiner les résultats
156
  const combinedData = [
157
  ...beforeSample.data,
158
  ...visibleSample.data,
@@ -174,7 +174,7 @@ export class AdaptiveSampler {
174
  }
175
 
176
  /**
177
- * Détection des features importantes dans la série
178
  */
179
  detectFeatures(data) {
180
  const peaks = [];
@@ -186,10 +186,10 @@ export class AdaptiveSampler {
186
  const current = data[i].value;
187
  const prev = data[i - 1].value;
188
  const next = data[i + 1].value;
189
-
190
- // Détection des pics locaux
191
  if (current > prev && current > next) {
192
- // Vérifier si c'est un pic significatif
193
  const localMax = Math.max(
194
  ...data.slice(i - window, i + window + 1).map(d => d.value)
195
  );
@@ -197,8 +197,8 @@ export class AdaptiveSampler {
197
  peaks.push(i);
198
  }
199
  }
200
-
201
- // Détection des vallées locales
202
  if (current < prev && current < next) {
203
  const localMin = Math.min(
204
  ...data.slice(i - window, i + window + 1).map(d => d.value)
@@ -207,12 +207,12 @@ export class AdaptiveSampler {
207
  valleys.push(i);
208
  }
209
  }
210
-
211
- // Détection des points d'inflection (changement de courbure)
212
  if (i >= 2 && i < data.length - 2) {
213
  const trend1 = data[i].value - data[i - 2].value;
214
  const trend2 = data[i + 2].value - data[i].value;
215
-
216
  if (Math.sign(trend1) !== Math.sign(trend2) && Math.abs(trend1) > 0.01 && Math.abs(trend2) > 0.01) {
217
  inflectionPoints.push(i);
218
  }
@@ -223,13 +223,13 @@ export class AdaptiveSampler {
223
  }
224
 
225
  /**
226
- * Génère des indices avec espacement logarithmique
227
  */
228
  generateLogSpacing(totalLength, count) {
229
  const indices = [];
230
  for (let i = 1; i <= count; i++) {
231
  const progress = i / (count + 1);
232
- // Fonction logarithmique pour plus de densité au début
233
  const logProgress = Math.log(1 + progress * (Math.E - 1)) / Math.log(Math.E);
234
  const index = Math.floor(logProgress * (totalLength - 1));
235
  indices.push(Math.max(1, Math.min(totalLength - 2, index)));
@@ -242,28 +242,28 @@ export class AdaptiveSampler {
242
  */
243
  sampleByVariation(data, targetPoints) {
244
  const variations = [];
245
-
246
- // Calculer la variation locale pour chaque point
247
  for (let i = 1; i < data.length - 1; i++) {
248
  const prev = data[i - 1].value;
249
  const curr = data[i].value;
250
  const next = data[i + 1].value;
251
-
252
- // Variation = différence avec la moyenne des voisins
253
  const avgNeighbors = (prev + next) / 2;
254
  const variation = Math.abs(curr - avgNeighbors);
255
-
256
  variations.push({ index: i, variation });
257
  }
258
-
259
- // Trier par variation décroissante et prendre les plus importantes
260
  variations.sort((a, b) => b.variation - a.variation);
261
-
262
  return variations.slice(0, targetPoints).map(v => v.index);
263
  }
264
 
265
  /**
266
- * Applique le sampling sur un objet de données complètes (multi-run)
267
  */
268
  sampleMetricData(metricData, strategy = null) {
269
  const sampledData = {};
@@ -272,7 +272,7 @@ export class AdaptiveSampler {
272
  Object.keys(metricData).forEach(runName => {
273
  const runData = metricData[runName] || [];
274
  const result = this.sampleSeries(runData, strategy);
275
-
276
  sampledData[runName] = result.data;
277
  samplingInfo[runName] = {
278
  originalLength: runData.length,
@@ -287,20 +287,20 @@ export class AdaptiveSampler {
287
  }
288
 
289
  /**
290
- * Reconstruit les données complètes pour une zone spécifique (pour le zoom)
291
  */
292
  getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
293
  // This method would allow recovering more details
294
- // quand l'utilisateur zoom sur une zone spécifique
295
  const startIdx = originalData.findIndex(d => d.step >= startStep);
296
  const endIdx = originalData.findIndex(d => d.step > endStep);
297
-
298
  return originalData.slice(startIdx, endIdx === -1 ? undefined : endIdx);
299
  }
300
  }
301
 
302
  /**
303
- * Instance globale configurée pour TrackIO
304
  */
305
  export const trackioSampler = new AdaptiveSampler({
306
  maxPoints: 400,
@@ -310,7 +310,7 @@ export const trackioSampler = new AdaptiveSampler({
310
  });
311
 
312
  /**
313
- * Fonction utilitaire pour usage direct
314
  */
315
  export function sampleLargeDataset(metricData, options = {}) {
316
  const sampler = new AdaptiveSampler(options);
 
32
  }
33
 
34
  const actualStrategy = strategy || this.options.adaptiveStrategy;
35
+
36
  if (!this.needsSampling(data.length)) {
37
+ return {
38
+ data: data.slice(),
39
  sampledIndices: data.map((_, i) => i),
40
  compressionRatio: 1,
41
  strategy: 'none'
 
88
  }
89
 
90
  /**
91
+ * Smart sampling - preserves important features
92
+ * Inspired by Douglas-Peucker algorithm adapted for time series
93
  */
94
  smartSampling(data) {
95
  const targetPoints = this.options.targetPoints;
96
  const features = this.detectFeatures(data);
97
+
98
+ // Step 1: Critical points (start, end, important features)
99
  const criticalPoints = new Set([0, data.length - 1]);
100
+
101
+ // Add detected features
102
  features.peaks.forEach(idx => criticalPoints.add(idx));
103
  features.valleys.forEach(idx => criticalPoints.add(idx));
104
  features.inflectionPoints.forEach(idx => criticalPoints.add(idx));
105
 
106
+ // Step 2: Logarithmic distribution to preserve density
107
  const remaining = targetPoints - criticalPoints.size;
108
  if (remaining > 0) {
109
  const logSamples = this.generateLogSpacing(data.length, remaining);
110
  logSamples.forEach(idx => criticalPoints.add(idx));
111
  }
112
 
113
+ // Step 3: Adaptive density in zones of change
114
  if (criticalPoints.size < targetPoints) {
115
  const variationSamples = this.sampleByVariation(data, targetPoints - criticalPoints.size);
116
  variationSamples.forEach(idx => criticalPoints.add(idx));
 
129
  }
130
 
131
  /**
132
+ * Level-of-Detail sampling - adaptive based on zoom/context
133
  */
134
  lodSampling(data, viewportStart = 0, viewportEnd = 1, zoomLevel = 1) {
135
  const viewStart = Math.floor(viewportStart * data.length);
136
  const viewEnd = Math.ceil(viewportEnd * data.length);
137
  const viewData = data.slice(viewStart, viewEnd);
138
+
139
+ // More detail in the visible area
140
  const visibleTargetPoints = Math.floor(this.options.targetPoints * 0.7);
141
  const contextTargetPoints = this.options.targetPoints - visibleTargetPoints;
142
+
143
+ // Dense sampling in the visible area
144
  const visibleSample = this.smartSampling(viewData);
145
+
146
+ // Sparse sampling in the context
147
  const beforeContext = data.slice(0, viewStart);
148
  const afterContext = data.slice(viewEnd);
149
+
150
+ const beforeSample = beforeContext.length > 0 ?
151
  this.uniformSampling(beforeContext) : { data: [], sampledIndices: [] };
152
+ const afterSample = afterContext.length > 0 ?
153
  this.uniformSampling(afterContext) : { data: [], sampledIndices: [] };
154
 
155
+ // Combine results
156
  const combinedData = [
157
  ...beforeSample.data,
158
  ...visibleSample.data,
 
174
  }
175
 
176
  /**
177
+ * Detect important features in the series
178
  */
179
  detectFeatures(data) {
180
  const peaks = [];
 
186
  const current = data[i].value;
187
  const prev = data[i - 1].value;
188
  const next = data[i + 1].value;
189
+
190
+ // Detect local peaks
191
  if (current > prev && current > next) {
192
+ // Check if it's a significant peak
193
  const localMax = Math.max(
194
  ...data.slice(i - window, i + window + 1).map(d => d.value)
195
  );
 
197
  peaks.push(i);
198
  }
199
  }
200
+
201
+ // Detect local valleys
202
  if (current < prev && current < next) {
203
  const localMin = Math.min(
204
  ...data.slice(i - window, i + window + 1).map(d => d.value)
 
207
  valleys.push(i);
208
  }
209
  }
210
+
211
+ // Detect inflection points (curvature change)
212
  if (i >= 2 && i < data.length - 2) {
213
  const trend1 = data[i].value - data[i - 2].value;
214
  const trend2 = data[i + 2].value - data[i].value;
215
+
216
  if (Math.sign(trend1) !== Math.sign(trend2) && Math.abs(trend1) > 0.01 && Math.abs(trend2) > 0.01) {
217
  inflectionPoints.push(i);
218
  }
 
223
  }
224
 
225
  /**
226
+ * Generate indices with logarithmic spacing
227
  */
228
  generateLogSpacing(totalLength, count) {
229
  const indices = [];
230
  for (let i = 1; i <= count; i++) {
231
  const progress = i / (count + 1);
232
+ // Logarithmic function for more density at the beginning
233
  const logProgress = Math.log(1 + progress * (Math.E - 1)) / Math.log(Math.E);
234
  const index = Math.floor(logProgress * (totalLength - 1));
235
  indices.push(Math.max(1, Math.min(totalLength - 2, index)));
 
242
  */
243
  sampleByVariation(data, targetPoints) {
244
  const variations = [];
245
+
246
+ // Calculate local variation for each point
247
  for (let i = 1; i < data.length - 1; i++) {
248
  const prev = data[i - 1].value;
249
  const curr = data[i].value;
250
  const next = data[i + 1].value;
251
+
252
+ // Variation = difference from the average of neighbors
253
  const avgNeighbors = (prev + next) / 2;
254
  const variation = Math.abs(curr - avgNeighbors);
255
+
256
  variations.push({ index: i, variation });
257
  }
258
+
259
+ // Sort by decreasing variation and take the most important ones
260
  variations.sort((a, b) => b.variation - a.variation);
261
+
262
  return variations.slice(0, targetPoints).map(v => v.index);
263
  }
264
 
265
  /**
266
+ * Apply sampling on a complete data object (multi-run)
267
  */
268
  sampleMetricData(metricData, strategy = null) {
269
  const sampledData = {};
 
272
  Object.keys(metricData).forEach(runName => {
273
  const runData = metricData[runName] || [];
274
  const result = this.sampleSeries(runData, strategy);
275
+
276
  sampledData[runName] = result.data;
277
  samplingInfo[runName] = {
278
  originalLength: runData.length,
 
287
  }
288
 
289
  /**
290
+ * Rebuild full data for a specific range (for zoom)
291
  */
292
  getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
293
  // This method would allow recovering more details
294
+ // when the user zooms on a specific area
295
  const startIdx = originalData.findIndex(d => d.step >= startStep);
296
  const endIdx = originalData.findIndex(d => d.step > endStep);
297
+
298
  return originalData.slice(startIdx, endIdx === -1 ? undefined : endIdx);
299
  }
300
  }
301
 
302
  /**
303
+ * Global instance configured for TrackIO
304
  */
305
  export const trackioSampler = new AdaptiveSampler({
306
  maxPoints: 400,
 
310
  });
311
 
312
  /**
313
+ * Utility function for direct usage
314
  */
315
  export function sampleLargeDataset(metricData, options = {}) {
316
  const sampler = new AdaptiveSampler(options);
app/src/components/trackio/core/data-generator.js CHANGED
@@ -11,12 +11,12 @@ export const Random = {
11
  // Basic random generators
12
  between: (min, max) => min + Math.random() * (max - min),
13
  intBetween: (min, max) => Math.floor(Random.between(min, max + 1)),
14
-
15
  // ML-specific generators
16
  learningRate: () => Random.between(0.02, 0.08),
17
- noiseAmplitude: (baseValue, reduction = 0.8) => (factor) =>
18
  (Random.between(-1, 1) * baseValue * (1 - reduction * factor)),
19
-
20
  // Training quality simulation
21
  trainingQuality: () => {
22
  const quality = Math.random();
@@ -27,7 +27,7 @@ export const Random = {
27
  score: quality
28
  };
29
  },
30
-
31
  // Learning phases (plateau, improvements, etc.)
32
  learningPhases: (maxSteps) => {
33
  const phases = Random.intBetween(1, 3);
@@ -41,32 +41,32 @@ export const Random = {
41
  // Training steps count with realistic ML training ranges (with large dataset support)
42
  trainingSteps: () => {
43
  const rand = Math.random();
44
-
45
- // Distribution basée sur des patterns d'entraînement ML réels
46
- // Inclut maintenant des datasets plus larges pour tester le sampling
47
  if (rand < 0.05) {
48
- // 5% - Très court : Tests rapides, prototypage
49
  return Random.intBetween(5, 50);
50
  } else if (rand < 0.15) {
51
- // 10% - Court : Expérimentations rapides
52
  return Random.intBetween(50, 200);
53
  } else if (rand < 0.35) {
54
- // 20% - Moyen-court : Entraînements standards
55
  return Random.intBetween(200, 400);
56
  } else if (rand < 0.55) {
57
- // 20% - Moyen : La plupart des entraînements
58
  return Random.intBetween(400, 800);
59
  } else if (rand < 0.75) {
60
- // 20% - Long : Entraînements approfondis (déclenche le sampling)
61
  return Random.intBetween(800, 1500);
62
  } else if (rand < 0.90) {
63
- // 15% - Très long : Large-scale training
64
  return Random.intBetween(1500, 3000);
65
  } else if (rand < 0.98) {
66
- // 8% - Extrêmement long : Research-scale
67
  return Random.intBetween(3000, 5000);
68
  } else {
69
- // 2% - Massive : LLMs, très gros datasets (pour tester les limites)
70
  return Random.intBetween(5000, 10000);
71
  }
72
  },
@@ -85,7 +85,7 @@ export const Random = {
85
  case 'llm':
86
  return Random.intBetween(2000, 5000);
87
  case 'massive':
88
- // Nouveau scénario pour tester le sampling avec de très gros datasets
89
  return Random.intBetween(5000, 15000);
90
  default:
91
  return Random.trainingSteps();
@@ -105,7 +105,7 @@ export const TrainingConfig = {
105
  SPIKE_AMPLITUDE: 0.15,
106
  DECAY_ACCELERATION: 1.6
107
  },
108
-
109
  ACCURACY: {
110
  INITIAL_MIN: 0.1,
111
  INITIAL_MAX: 0.45,
@@ -115,7 +115,7 @@ export const TrainingConfig = {
115
  NOISE_AMPLITUDE: 0.04,
116
  PHASE_ACCELERATION: 1.4
117
  },
118
-
119
  OVERFITTING: {
120
  START_RATIO_GOOD: 0.85,
121
  START_RATIO_POOR: 0.7,
@@ -123,7 +123,7 @@ export const TrainingConfig = {
123
  ACCURACY_DEGRADATION: 0.03,
124
  LOSS_INCREASE: 0.12
125
  },
126
-
127
  VALIDATION_GAP: {
128
  ACCURACY_MIN: 0.02,
129
  ACCURACY_MAX: 0.06,
@@ -140,18 +140,18 @@ export const Performance = {
140
  // Smart sampling for large datasets to maintain performance
141
  smartSample: (totalSteps, maxPoints = 2000) => {
142
  if (totalSteps <= maxPoints) {
143
- return Array.from({length: totalSteps}, (_, i) => i + 1);
144
  }
145
-
146
  // For large datasets, sample intelligently:
147
  // - Always include start and end
148
  // - Keep more density at the beginning (where learning happens faster)
149
  // - Sample logarithmically for the middle section
150
  // - Always include some regular intervals
151
-
152
  const samples = new Set([1, totalSteps]); // Always include first and last
153
  const targetSamples = Math.min(maxPoints, totalSteps);
154
-
155
  // Add logarithmic sampling (more points early, fewer later)
156
  const logSamples = Math.floor(targetSamples * 0.6);
157
  for (let i = 0; i < logSamples; i++) {
@@ -160,7 +160,7 @@ export const Performance = {
160
  const step = Math.floor(1 + logProgress * (totalSteps - 1));
161
  samples.add(step);
162
  }
163
-
164
  // Add regular intervals for the remaining points
165
  const remainingSamples = targetSamples - samples.size;
166
  const interval = Math.floor(totalSteps / remainingSamples);
@@ -168,7 +168,7 @@ export const Performance = {
168
  samples.add(i);
169
  if (samples.size >= targetSamples) break;
170
  }
171
-
172
  return Array.from(samples).sort((a, b) => a - b);
173
  }
174
  };
@@ -209,35 +209,35 @@ function calculateTargetAccuracy(quality) {
209
  function generateLossCurve(steps, initialLoss, targetLoss, learningPhases, quality) {
210
  let learningRate = Random.learningRate();
211
  const loss = new Array(steps);
212
-
213
  for (let phaseIndex = 0; phaseIndex < learningPhases.length - 1; phaseIndex++) {
214
  const phaseStart = learningPhases[phaseIndex];
215
  const phaseEnd = learningPhases[phaseIndex + 1] || phaseStart + 1;
216
-
217
  for (let step = phaseStart; step <= phaseEnd; step++) {
218
  const phaseProgress = (step - phaseStart) / Math.max(1, phaseEnd - phaseStart);
219
  const phaseTarget = targetLoss * Math.pow(0.85, phaseIndex);
220
-
221
  // Exponential decay with phase blending
222
  let value = initialLoss * Math.exp(-learningRate * (step + 1));
223
  value = 0.6 * value + 0.4 * (initialLoss + (phaseTarget - initialLoss) * (phaseIndex + phaseProgress) / Math.max(1, learningPhases.length - 1));
224
-
225
  // Add realistic noise that decreases over time
226
  const noiseGen = Random.noiseAmplitude(TrainingConfig.LOSS.NOISE_FACTOR * initialLoss);
227
  value += noiseGen(step / (steps - 1));
228
-
229
  // Occasional loss spikes (common in training)
230
  if (Math.random() < TrainingConfig.LOSS.SPIKE_PROBABILITY) {
231
  value += TrainingConfig.LOSS.SPIKE_AMPLITUDE * initialLoss;
232
  }
233
-
234
  loss[step] = Math.max(0, value);
235
  }
236
-
237
  // Learning rate changes between phases
238
  learningRate *= TrainingConfig.LOSS.DECAY_ACCELERATION;
239
  }
240
-
241
  return loss;
242
  }
243
 
@@ -248,23 +248,23 @@ function generateAccuracyCurve(steps, targetAccuracy, learningPhases, quality) {
248
  const initialAccuracy = Random.between(TrainingConfig.ACCURACY.INITIAL_MIN, TrainingConfig.ACCURACY.INITIAL_MAX);
249
  let learningRate = Random.learningRate();
250
  const accuracy = new Array(steps);
251
-
252
  for (let step = 0; step < steps; step++) {
253
  // Asymptotic growth towards target accuracy
254
  let value = targetAccuracy - (targetAccuracy - initialAccuracy) * Math.exp(-learningRate * (step + 1));
255
-
256
  // Add realistic noise that decreases over time
257
  const noiseGen = Random.noiseAmplitude(TrainingConfig.ACCURACY.NOISE_AMPLITUDE);
258
  value += noiseGen(step / (steps - 1));
259
-
260
  accuracy[step] = Math.max(0, Math.min(1, value));
261
-
262
  // Accelerate learning at phase boundaries
263
  if (learningPhases.includes(step)) {
264
  learningRate *= TrainingConfig.ACCURACY.PHASE_ACCELERATION;
265
  }
266
  }
267
-
268
  return accuracy;
269
  }
270
 
@@ -274,58 +274,58 @@ function generateAccuracyCurve(steps, targetAccuracy, learningPhases, quality) {
274
  function applyOverfitting(trainCurve, steps, quality) {
275
  const validationCurve = new Array(steps);
276
  const gapConfig = TrainingConfig.VALIDATION_GAP;
277
-
278
  // Calculate when overfitting starts
279
  const overfittingStart = Math.floor(
280
- (quality.isGood ? TrainingConfig.OVERFITTING.START_RATIO_GOOD : TrainingConfig.OVERFITTING.START_RATIO_POOR)
281
  * (steps - 1) + Random.between(-TrainingConfig.OVERFITTING.RANDOMNESS, TrainingConfig.OVERFITTING.RANDOMNESS) * steps
282
  );
283
-
284
  const clampedStart = Math.max(Math.floor(0.5 * (steps - 1)), Math.min(Math.floor(0.95 * (steps - 1)), overfittingStart));
285
-
286
  for (let step = 0; step < steps; step++) {
287
  const isAccuracy = trainCurve[step] <= 1; // Simple heuristic
288
- const baseGap = isAccuracy
289
  ? Random.between(gapConfig.ACCURACY_MIN, gapConfig.ACCURACY_MAX)
290
  : Random.between(gapConfig.LOSS_MIN, gapConfig.LOSS_MAX);
291
-
292
- let validationValue = isAccuracy
293
- ? trainCurve[step] - baseGap + Random.between(-gapConfig.FLUCTUATION/2, gapConfig.FLUCTUATION/2)
294
  : trainCurve[step] * (1 + baseGap) + Random.between(-0.1, 0.1);
295
-
296
  // Apply overfitting effects after the overfitting point
297
  if (step >= clampedStart && !quality.isPoor) {
298
  const overfittingProgress = (step - clampedStart) / Math.max(1, steps - 1 - clampedStart);
299
-
300
  if (isAccuracy) {
301
  validationValue -= TrainingConfig.OVERFITTING.ACCURACY_DEGRADATION * overfittingProgress;
302
  } else {
303
  validationValue += TrainingConfig.OVERFITTING.LOSS_INCREASE * overfittingProgress * trainCurve[step];
304
  }
305
  }
306
-
307
- validationCurve[step] = isAccuracy
308
  ? Math.max(0, Math.min(1, validationValue))
309
  : Math.max(0, validationValue);
310
  }
311
-
312
  return validationCurve;
313
  }
314
 
315
  export function generateRunNames(count, stepsHint = null) {
316
  const adjectives = [
317
- 'ancient', 'brave', 'calm', 'clever', 'crimson', 'daring', 'eager', 'fearless',
318
- 'gentle', 'glossy', 'golden', 'hidden', 'icy', 'jolly', 'lively', 'mighty',
319
  'noble', 'proud', 'quick', 'silent', 'swift', 'tiny', 'vivid', 'wild'
320
  ];
321
-
322
  const nouns = [
323
- 'river', 'mountain', 'harbor', 'forest', 'valley', 'ocean', 'meadow', 'desert',
324
- 'island', 'canyon', 'harbor', 'trail', 'summit', 'delta', 'lagoon', 'ridge',
325
  'tundra', 'reef', 'plateau', 'prairie', 'grove', 'bay', 'dune', 'cliff'
326
  ];
327
-
328
- // Ajouter des préfixes selon la longueur de l'entraînement
329
  const getPrefix = (steps) => {
330
  if (!steps) return '';
331
  if (steps < 100) return 'rapid-';
@@ -334,18 +334,18 @@ export function generateRunNames(count, stepsHint = null) {
334
  if (steps < 50000) return 'ultra-';
335
  return 'mega-';
336
  };
337
-
338
  const used = new Set();
339
  const names = [];
340
  const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
341
-
342
  while (names.length < count) {
343
  const prefix = getPrefix(stepsHint);
344
  const adjective = pick(adjectives);
345
  const noun = pick(nouns);
346
  const suffix = Math.floor(1 + Math.random() * 99);
347
  const name = `${prefix}${adjective}-${noun}-${suffix}`;
348
-
349
  if (!used.has(name)) {
350
  used.add(name);
351
  names.push(name);
@@ -378,23 +378,23 @@ export function generateMassiveTestDataset(steps = null, runs = 3) {
378
  const actualSteps = steps || Random.trainingStepsForScenario('massive');
379
  const runNames = generateRunNames(runs, actualSteps);
380
  const dataByMetric = new Map();
381
-
382
  console.log(`🧪 Generating massive test dataset: ${actualSteps} steps × ${runs} runs = ${actualSteps * runs} total points`);
383
-
384
  const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
385
-
386
  // Initialize data structure
387
  TARGET_METRICS.forEach((metric) => {
388
  const map = {};
389
  runNames.forEach((r) => { map[r] = []; });
390
  dataByMetric.set(metric, map);
391
  });
392
-
393
  // Generate curves for each run
394
  runNames.forEach((run, runIndex) => {
395
  console.log(`🔄 Generating curves for run ${runIndex + 1}/${runs}: ${run}`);
396
  const curves = genCurves(actualSteps);
397
-
398
  for (let stepIndex = 0; stepIndex < actualSteps; stepIndex++) {
399
  const step = stepIndex + 1;
400
  dataByMetric.get('epoch')[run].push({ step, value: step });
@@ -404,9 +404,9 @@ export function generateMassiveTestDataset(steps = null, runs = 3) {
404
  dataByMetric.get('val_loss')[run].push({ step, value: curves.lossVal[stepIndex] });
405
  }
406
  });
407
-
408
  console.log(`✅ Massive dataset generated successfully`);
409
-
410
  return {
411
  dataByMetric,
412
  runNames,
@@ -426,47 +426,47 @@ export function genCurves(totalSteps, maxPoints = 2000) {
426
  // 1. Smart sampling for performance - get the actual steps we'll compute
427
  const sampledSteps = Performance.smartSample(totalSteps, maxPoints);
428
  const actualPointsCount = sampledSteps.length;
429
-
430
  // 2. Determine overall training quality and characteristics
431
  const quality = Random.trainingQuality();
432
-
433
  // 3. Generate target metrics based on quality
434
  const initialLoss = Random.between(TrainingConfig.LOSS.INITIAL_MIN, TrainingConfig.LOSS.INITIAL_MAX);
435
  const targetLoss = calculateTargetLoss(initialLoss, quality);
436
  const targetAccuracy = calculateTargetAccuracy(quality);
437
-
438
  // 4. Generate learning phases (plateaus, rapid improvements, etc.)
439
  const learningPhases = Random.learningPhases(totalSteps);
440
-
441
  // 5. Generate realistic training curves (using sampled steps for computation)
442
  const trainLoss = generateLossCurveOptimized(sampledSteps, totalSteps, initialLoss, targetLoss, learningPhases, quality);
443
  const trainAccuracy = generateAccuracyCurveOptimized(sampledSteps, totalSteps, targetAccuracy, learningPhases, quality);
444
-
445
  // 6. Apply overfitting to create validation curves
446
  const validationLoss = applyOverfittingOptimized(trainLoss, sampledSteps, totalSteps, quality);
447
  const validationAccuracy = applyOverfittingOptimized(trainAccuracy, sampledSteps, totalSteps, quality);
448
-
449
  // Convert back to simple arrays for backward compatibility
450
  // Create arrays indexed by step position for the original step sequence
451
  const stepToIndex = new Map();
452
  sampledSteps.forEach((step, index) => {
453
  stepToIndex.set(step, index);
454
  });
455
-
456
  // Create full arrays with interpolation for missing steps
457
  const createCompatibleArray = (sampledData) => {
458
  const result = new Array(totalSteps);
459
  let lastValue = sampledData[0]?.value || 0;
460
-
461
  // Ensure initial value is valid
462
  if (!Number.isFinite(lastValue)) {
463
  lastValue = 0;
464
  }
465
-
466
  for (let i = 0; i < totalSteps; i++) {
467
  const step = i + 1;
468
  const sampledIndex = stepToIndex.get(step);
469
-
470
  if (sampledIndex !== undefined) {
471
  // We have data for this step
472
  const newValue = sampledData[sampledIndex].value;
@@ -477,7 +477,7 @@ export function genCurves(totalSteps, maxPoints = 2000) {
477
  result[i] = lastValue;
478
  }
479
  }
480
-
481
  return result;
482
  };
483
 
@@ -485,11 +485,11 @@ export function genCurves(totalSteps, maxPoints = 2000) {
485
  // Training curves (what the model sees during training) - compatible format
486
  accTrain: createCompatibleArray(trainAccuracy),
487
  lossTrain: createCompatibleArray(trainLoss),
488
-
489
  // Validation curves (held-out data, shows generalization) - compatible format
490
  accVal: createCompatibleArray(validationAccuracy),
491
  lossVal: createCompatibleArray(validationLoss),
492
-
493
  // Metadata for debugging
494
  _meta: {
495
  totalSteps,
@@ -498,7 +498,7 @@ export function genCurves(totalSteps, maxPoints = 2000) {
498
  quality: quality.score
499
  }
500
  };
501
-
502
  // Debug: Check for NaN values
503
  const hasNaN = (arr, name) => {
504
  const nanCount = arr.filter(v => !Number.isFinite(v)).length;
@@ -506,14 +506,14 @@ export function genCurves(totalSteps, maxPoints = 2000) {
506
  console.warn(`⚠️ Found ${nanCount} NaN values in ${name}`);
507
  }
508
  };
509
-
510
  if (totalSteps > 1000) { // Only debug large datasets
511
  hasNaN(result.accTrain, 'accTrain');
512
  hasNaN(result.lossTrain, 'lossTrain');
513
  hasNaN(result.accVal, 'accVal');
514
  hasNaN(result.lossVal, 'lossVal');
515
  }
516
-
517
  return result;
518
  }
519
 
@@ -527,7 +527,7 @@ export function genCurves(totalSteps, maxPoints = 2000) {
527
  function generateLossCurveOptimized(sampledSteps, totalSteps, initialLoss, targetLoss, learningPhases, quality) {
528
  let learningRate = Random.learningRate();
529
  const loss = [];
530
-
531
  // Create a mapping function from sampled steps to values
532
  sampledSteps.forEach((step, index) => {
533
  // Find which learning phase this step belongs to
@@ -538,30 +538,30 @@ function generateLossCurveOptimized(sampledSteps, totalSteps, initialLoss, targe
538
  break;
539
  }
540
  }
541
-
542
  const phaseStart = learningPhases[phaseIndex];
543
  const phaseEnd = learningPhases[phaseIndex + 1] || totalSteps;
544
  const phaseProgress = (step - phaseStart) / Math.max(1, phaseEnd - phaseStart);
545
  const phaseTarget = targetLoss * Math.pow(0.85, phaseIndex);
546
-
547
  // Exponential decay with phase blending
548
  let value = initialLoss * Math.exp(-learningRate * (step / totalSteps) * 100);
549
  value = 0.6 * value + 0.4 * (initialLoss + (phaseTarget - initialLoss) * (phaseIndex + phaseProgress) / Math.max(1, learningPhases.length - 1));
550
-
551
  // Add realistic noise that decreases over time
552
  const noiseGen = Random.noiseAmplitude(TrainingConfig.LOSS.NOISE_FACTOR * initialLoss);
553
  value += noiseGen(step / totalSteps);
554
-
555
  // Occasional loss spikes (common in training)
556
  if (Math.random() < TrainingConfig.LOSS.SPIKE_PROBABILITY) {
557
  value += TrainingConfig.LOSS.SPIKE_AMPLITUDE * initialLoss;
558
  }
559
-
560
  // Ensure no NaN values
561
  const finalValue = Math.max(0, Number.isFinite(value) ? value : initialLoss * 0.1);
562
  loss.push({ step, value: finalValue });
563
  });
564
-
565
  return loss;
566
  }
567
 
@@ -572,25 +572,25 @@ function generateAccuracyCurveOptimized(sampledSteps, totalSteps, targetAccuracy
572
  const initialAccuracy = Random.between(TrainingConfig.ACCURACY.INITIAL_MIN, TrainingConfig.ACCURACY.INITIAL_MAX);
573
  let learningRate = Random.learningRate();
574
  const accuracy = [];
575
-
576
  sampledSteps.forEach((step, index) => {
577
  // Asymptotic growth towards target accuracy
578
  let value = targetAccuracy - (targetAccuracy - initialAccuracy) * Math.exp(-learningRate * (step / totalSteps) * 100);
579
-
580
  // Add realistic noise that decreases over time
581
  const noiseGen = Random.noiseAmplitude(TrainingConfig.ACCURACY.NOISE_AMPLITUDE);
582
  value += noiseGen(step / totalSteps);
583
-
584
  // Ensure no NaN values
585
  const finalValue = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0.1;
586
  accuracy.push({ step, value: finalValue });
587
-
588
  // Accelerate learning at phase boundaries
589
  if (learningPhases.includes(step)) {
590
  learningRate *= TrainingConfig.ACCURACY.PHASE_ACCELERATION;
591
  }
592
  });
593
-
594
  return accuracy;
595
  }
596
 
@@ -600,47 +600,47 @@ function generateAccuracyCurveOptimized(sampledSteps, totalSteps, targetAccuracy
600
  function applyOverfittingOptimized(trainCurve, sampledSteps, totalSteps, quality) {
601
  const validationCurve = [];
602
  const gapConfig = TrainingConfig.VALIDATION_GAP;
603
-
604
  // Calculate when overfitting starts
605
  const overfittingStart = Math.floor(
606
- (quality.isGood ? TrainingConfig.OVERFITTING.START_RATIO_GOOD : TrainingConfig.OVERFITTING.START_RATIO_POOR)
607
  * totalSteps + Random.between(-TrainingConfig.OVERFITTING.RANDOMNESS, TrainingConfig.OVERFITTING.RANDOMNESS) * totalSteps
608
  );
609
-
610
  const clampedStart = Math.max(Math.floor(0.5 * totalSteps), Math.min(Math.floor(0.95 * totalSteps), overfittingStart));
611
-
612
  trainCurve.forEach((trainPoint, index) => {
613
  const step = trainPoint.step;
614
  const isAccuracy = trainPoint.value <= 1; // Simple heuristic
615
- const baseGap = isAccuracy
616
  ? Random.between(gapConfig.ACCURACY_MIN, gapConfig.ACCURACY_MAX)
617
  : Random.between(gapConfig.LOSS_MIN, gapConfig.LOSS_MAX);
618
-
619
- let validationValue = isAccuracy
620
- ? trainPoint.value - baseGap + Random.between(-gapConfig.FLUCTUATION/2, gapConfig.FLUCTUATION/2)
621
  : trainPoint.value * (1 + baseGap) + Random.between(-0.1, 0.1);
622
-
623
  // Apply overfitting effects after the overfitting point
624
  if (step >= clampedStart && !quality.isPoor) {
625
  const overfittingProgress = (step - clampedStart) / Math.max(1, totalSteps - clampedStart);
626
-
627
  if (isAccuracy) {
628
  validationValue -= TrainingConfig.OVERFITTING.ACCURACY_DEGRADATION * overfittingProgress;
629
  } else {
630
  validationValue += TrainingConfig.OVERFITTING.LOSS_INCREASE * overfittingProgress * trainPoint.value;
631
  }
632
  }
633
-
634
  // Ensure no NaN values in validation curves
635
- const finalValue = Number.isFinite(validationValue)
636
  ? (isAccuracy ? Math.max(0, Math.min(1, validationValue)) : Math.max(0, validationValue))
637
  : (isAccuracy ? 0.1 : trainPoint.value);
638
-
639
  validationCurve.push({
640
  step,
641
  value: finalValue
642
  });
643
  });
644
-
645
  return validationCurve;
646
  }
 
11
  // Basic random generators
12
  between: (min, max) => min + Math.random() * (max - min),
13
  intBetween: (min, max) => Math.floor(Random.between(min, max + 1)),
14
+
15
  // ML-specific generators
16
  learningRate: () => Random.between(0.02, 0.08),
17
+ noiseAmplitude: (baseValue, reduction = 0.8) => (factor) =>
18
  (Random.between(-1, 1) * baseValue * (1 - reduction * factor)),
19
+
20
  // Training quality simulation
21
  trainingQuality: () => {
22
  const quality = Math.random();
 
27
  score: quality
28
  };
29
  },
30
+
31
  // Learning phases (plateau, improvements, etc.)
32
  learningPhases: (maxSteps) => {
33
  const phases = Random.intBetween(1, 3);
 
41
  // Training steps count with realistic ML training ranges (with large dataset support)
42
  trainingSteps: () => {
43
  const rand = Math.random();
44
+
45
+ // Distribution based on real ML training patterns
46
+ // Now includes larger datasets to test sampling
47
  if (rand < 0.05) {
48
+ // 5% - Very short: Quick tests, prototyping
49
  return Random.intBetween(5, 50);
50
  } else if (rand < 0.15) {
51
+ // 10% - Short: Quick experiments
52
  return Random.intBetween(50, 200);
53
  } else if (rand < 0.35) {
54
+ // 20% - Medium-short: Standard training
55
  return Random.intBetween(200, 400);
56
  } else if (rand < 0.55) {
57
+ // 20% - Medium: Most training runs
58
  return Random.intBetween(400, 800);
59
  } else if (rand < 0.75) {
60
+ // 20% - Long: In-depth training (triggers sampling)
61
  return Random.intBetween(800, 1500);
62
  } else if (rand < 0.90) {
63
+ // 15% - Very long: Large-scale training
64
  return Random.intBetween(1500, 3000);
65
  } else if (rand < 0.98) {
66
+ // 8% - Extremely long: Research-scale
67
  return Random.intBetween(3000, 5000);
68
  } else {
69
+ // 2% - Massive: LLMs, very large datasets (to test limits)
70
  return Random.intBetween(5000, 10000);
71
  }
72
  },
 
85
  case 'llm':
86
  return Random.intBetween(2000, 5000);
87
  case 'massive':
88
+ // New scenario to test sampling with very large datasets
89
  return Random.intBetween(5000, 15000);
90
  default:
91
  return Random.trainingSteps();
 
105
  SPIKE_AMPLITUDE: 0.15,
106
  DECAY_ACCELERATION: 1.6
107
  },
108
+
109
  ACCURACY: {
110
  INITIAL_MIN: 0.1,
111
  INITIAL_MAX: 0.45,
 
115
  NOISE_AMPLITUDE: 0.04,
116
  PHASE_ACCELERATION: 1.4
117
  },
118
+
119
  OVERFITTING: {
120
  START_RATIO_GOOD: 0.85,
121
  START_RATIO_POOR: 0.7,
 
123
  ACCURACY_DEGRADATION: 0.03,
124
  LOSS_INCREASE: 0.12
125
  },
126
+
127
  VALIDATION_GAP: {
128
  ACCURACY_MIN: 0.02,
129
  ACCURACY_MAX: 0.06,
 
140
  // Smart sampling for large datasets to maintain performance
141
  smartSample: (totalSteps, maxPoints = 2000) => {
142
  if (totalSteps <= maxPoints) {
143
+ return Array.from({ length: totalSteps }, (_, i) => i + 1);
144
  }
145
+
146
  // For large datasets, sample intelligently:
147
  // - Always include start and end
148
  // - Keep more density at the beginning (where learning happens faster)
149
  // - Sample logarithmically for the middle section
150
  // - Always include some regular intervals
151
+
152
  const samples = new Set([1, totalSteps]); // Always include first and last
153
  const targetSamples = Math.min(maxPoints, totalSteps);
154
+
155
  // Add logarithmic sampling (more points early, fewer later)
156
  const logSamples = Math.floor(targetSamples * 0.6);
157
  for (let i = 0; i < logSamples; i++) {
 
160
  const step = Math.floor(1 + logProgress * (totalSteps - 1));
161
  samples.add(step);
162
  }
163
+
164
  // Add regular intervals for the remaining points
165
  const remainingSamples = targetSamples - samples.size;
166
  const interval = Math.floor(totalSteps / remainingSamples);
 
168
  samples.add(i);
169
  if (samples.size >= targetSamples) break;
170
  }
171
+
172
  return Array.from(samples).sort((a, b) => a - b);
173
  }
174
  };
 
209
  function generateLossCurve(steps, initialLoss, targetLoss, learningPhases, quality) {
210
  let learningRate = Random.learningRate();
211
  const loss = new Array(steps);
212
+
213
  for (let phaseIndex = 0; phaseIndex < learningPhases.length - 1; phaseIndex++) {
214
  const phaseStart = learningPhases[phaseIndex];
215
  const phaseEnd = learningPhases[phaseIndex + 1] || phaseStart + 1;
216
+
217
  for (let step = phaseStart; step <= phaseEnd; step++) {
218
  const phaseProgress = (step - phaseStart) / Math.max(1, phaseEnd - phaseStart);
219
  const phaseTarget = targetLoss * Math.pow(0.85, phaseIndex);
220
+
221
  // Exponential decay with phase blending
222
  let value = initialLoss * Math.exp(-learningRate * (step + 1));
223
  value = 0.6 * value + 0.4 * (initialLoss + (phaseTarget - initialLoss) * (phaseIndex + phaseProgress) / Math.max(1, learningPhases.length - 1));
224
+
225
  // Add realistic noise that decreases over time
226
  const noiseGen = Random.noiseAmplitude(TrainingConfig.LOSS.NOISE_FACTOR * initialLoss);
227
  value += noiseGen(step / (steps - 1));
228
+
229
  // Occasional loss spikes (common in training)
230
  if (Math.random() < TrainingConfig.LOSS.SPIKE_PROBABILITY) {
231
  value += TrainingConfig.LOSS.SPIKE_AMPLITUDE * initialLoss;
232
  }
233
+
234
  loss[step] = Math.max(0, value);
235
  }
236
+
237
  // Learning rate changes between phases
238
  learningRate *= TrainingConfig.LOSS.DECAY_ACCELERATION;
239
  }
240
+
241
  return loss;
242
  }
243
 
 
248
  const initialAccuracy = Random.between(TrainingConfig.ACCURACY.INITIAL_MIN, TrainingConfig.ACCURACY.INITIAL_MAX);
249
  let learningRate = Random.learningRate();
250
  const accuracy = new Array(steps);
251
+
252
  for (let step = 0; step < steps; step++) {
253
  // Asymptotic growth towards target accuracy
254
  let value = targetAccuracy - (targetAccuracy - initialAccuracy) * Math.exp(-learningRate * (step + 1));
255
+
256
  // Add realistic noise that decreases over time
257
  const noiseGen = Random.noiseAmplitude(TrainingConfig.ACCURACY.NOISE_AMPLITUDE);
258
  value += noiseGen(step / (steps - 1));
259
+
260
  accuracy[step] = Math.max(0, Math.min(1, value));
261
+
262
  // Accelerate learning at phase boundaries
263
  if (learningPhases.includes(step)) {
264
  learningRate *= TrainingConfig.ACCURACY.PHASE_ACCELERATION;
265
  }
266
  }
267
+
268
  return accuracy;
269
  }
270
 
 
274
  function applyOverfitting(trainCurve, steps, quality) {
275
  const validationCurve = new Array(steps);
276
  const gapConfig = TrainingConfig.VALIDATION_GAP;
277
+
278
  // Calculate when overfitting starts
279
  const overfittingStart = Math.floor(
280
+ (quality.isGood ? TrainingConfig.OVERFITTING.START_RATIO_GOOD : TrainingConfig.OVERFITTING.START_RATIO_POOR)
281
  * (steps - 1) + Random.between(-TrainingConfig.OVERFITTING.RANDOMNESS, TrainingConfig.OVERFITTING.RANDOMNESS) * steps
282
  );
283
+
284
  const clampedStart = Math.max(Math.floor(0.5 * (steps - 1)), Math.min(Math.floor(0.95 * (steps - 1)), overfittingStart));
285
+
286
  for (let step = 0; step < steps; step++) {
287
  const isAccuracy = trainCurve[step] <= 1; // Simple heuristic
288
+ const baseGap = isAccuracy
289
  ? Random.between(gapConfig.ACCURACY_MIN, gapConfig.ACCURACY_MAX)
290
  : Random.between(gapConfig.LOSS_MIN, gapConfig.LOSS_MAX);
291
+
292
+ let validationValue = isAccuracy
293
+ ? trainCurve[step] - baseGap + Random.between(-gapConfig.FLUCTUATION / 2, gapConfig.FLUCTUATION / 2)
294
  : trainCurve[step] * (1 + baseGap) + Random.between(-0.1, 0.1);
295
+
296
  // Apply overfitting effects after the overfitting point
297
  if (step >= clampedStart && !quality.isPoor) {
298
  const overfittingProgress = (step - clampedStart) / Math.max(1, steps - 1 - clampedStart);
299
+
300
  if (isAccuracy) {
301
  validationValue -= TrainingConfig.OVERFITTING.ACCURACY_DEGRADATION * overfittingProgress;
302
  } else {
303
  validationValue += TrainingConfig.OVERFITTING.LOSS_INCREASE * overfittingProgress * trainCurve[step];
304
  }
305
  }
306
+
307
+ validationCurve[step] = isAccuracy
308
  ? Math.max(0, Math.min(1, validationValue))
309
  : Math.max(0, validationValue);
310
  }
311
+
312
  return validationCurve;
313
  }
314
 
315
  export function generateRunNames(count, stepsHint = null) {
316
  const adjectives = [
317
+ 'ancient', 'brave', 'calm', 'clever', 'crimson', 'daring', 'eager', 'fearless',
318
+ 'gentle', 'glossy', 'golden', 'hidden', 'icy', 'jolly', 'lively', 'mighty',
319
  'noble', 'proud', 'quick', 'silent', 'swift', 'tiny', 'vivid', 'wild'
320
  ];
321
+
322
  const nouns = [
323
+ 'river', 'mountain', 'harbor', 'forest', 'valley', 'ocean', 'meadow', 'desert',
324
+ 'island', 'canyon', 'harbor', 'trail', 'summit', 'delta', 'lagoon', 'ridge',
325
  'tundra', 'reef', 'plateau', 'prairie', 'grove', 'bay', 'dune', 'cliff'
326
  ];
327
+
328
+ // Add prefixes based on training length
329
  const getPrefix = (steps) => {
330
  if (!steps) return '';
331
  if (steps < 100) return 'rapid-';
 
334
  if (steps < 50000) return 'ultra-';
335
  return 'mega-';
336
  };
337
+
338
  const used = new Set();
339
  const names = [];
340
  const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
341
+
342
  while (names.length < count) {
343
  const prefix = getPrefix(stepsHint);
344
  const adjective = pick(adjectives);
345
  const noun = pick(nouns);
346
  const suffix = Math.floor(1 + Math.random() * 99);
347
  const name = `${prefix}${adjective}-${noun}-${suffix}`;
348
+
349
  if (!used.has(name)) {
350
  used.add(name);
351
  names.push(name);
 
378
  const actualSteps = steps || Random.trainingStepsForScenario('massive');
379
  const runNames = generateRunNames(runs, actualSteps);
380
  const dataByMetric = new Map();
381
+
382
  console.log(`🧪 Generating massive test dataset: ${actualSteps} steps × ${runs} runs = ${actualSteps * runs} total points`);
383
+
384
  const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
385
+
386
  // Initialize data structure
387
  TARGET_METRICS.forEach((metric) => {
388
  const map = {};
389
  runNames.forEach((r) => { map[r] = []; });
390
  dataByMetric.set(metric, map);
391
  });
392
+
393
  // Generate curves for each run
394
  runNames.forEach((run, runIndex) => {
395
  console.log(`🔄 Generating curves for run ${runIndex + 1}/${runs}: ${run}`);
396
  const curves = genCurves(actualSteps);
397
+
398
  for (let stepIndex = 0; stepIndex < actualSteps; stepIndex++) {
399
  const step = stepIndex + 1;
400
  dataByMetric.get('epoch')[run].push({ step, value: step });
 
404
  dataByMetric.get('val_loss')[run].push({ step, value: curves.lossVal[stepIndex] });
405
  }
406
  });
407
+
408
  console.log(`✅ Massive dataset generated successfully`);
409
+
410
  return {
411
  dataByMetric,
412
  runNames,
 
426
  // 1. Smart sampling for performance - get the actual steps we'll compute
427
  const sampledSteps = Performance.smartSample(totalSteps, maxPoints);
428
  const actualPointsCount = sampledSteps.length;
429
+
430
  // 2. Determine overall training quality and characteristics
431
  const quality = Random.trainingQuality();
432
+
433
  // 3. Generate target metrics based on quality
434
  const initialLoss = Random.between(TrainingConfig.LOSS.INITIAL_MIN, TrainingConfig.LOSS.INITIAL_MAX);
435
  const targetLoss = calculateTargetLoss(initialLoss, quality);
436
  const targetAccuracy = calculateTargetAccuracy(quality);
437
+
438
  // 4. Generate learning phases (plateaus, rapid improvements, etc.)
439
  const learningPhases = Random.learningPhases(totalSteps);
440
+
441
  // 5. Generate realistic training curves (using sampled steps for computation)
442
  const trainLoss = generateLossCurveOptimized(sampledSteps, totalSteps, initialLoss, targetLoss, learningPhases, quality);
443
  const trainAccuracy = generateAccuracyCurveOptimized(sampledSteps, totalSteps, targetAccuracy, learningPhases, quality);
444
+
445
  // 6. Apply overfitting to create validation curves
446
  const validationLoss = applyOverfittingOptimized(trainLoss, sampledSteps, totalSteps, quality);
447
  const validationAccuracy = applyOverfittingOptimized(trainAccuracy, sampledSteps, totalSteps, quality);
448
+
449
  // Convert back to simple arrays for backward compatibility
450
  // Create arrays indexed by step position for the original step sequence
451
  const stepToIndex = new Map();
452
  sampledSteps.forEach((step, index) => {
453
  stepToIndex.set(step, index);
454
  });
455
+
456
  // Create full arrays with interpolation for missing steps
457
  const createCompatibleArray = (sampledData) => {
458
  const result = new Array(totalSteps);
459
  let lastValue = sampledData[0]?.value || 0;
460
+
461
  // Ensure initial value is valid
462
  if (!Number.isFinite(lastValue)) {
463
  lastValue = 0;
464
  }
465
+
466
  for (let i = 0; i < totalSteps; i++) {
467
  const step = i + 1;
468
  const sampledIndex = stepToIndex.get(step);
469
+
470
  if (sampledIndex !== undefined) {
471
  // We have data for this step
472
  const newValue = sampledData[sampledIndex].value;
 
477
  result[i] = lastValue;
478
  }
479
  }
480
+
481
  return result;
482
  };
483
 
 
485
  // Training curves (what the model sees during training) - compatible format
486
  accTrain: createCompatibleArray(trainAccuracy),
487
  lossTrain: createCompatibleArray(trainLoss),
488
+
489
  // Validation curves (held-out data, shows generalization) - compatible format
490
  accVal: createCompatibleArray(validationAccuracy),
491
  lossVal: createCompatibleArray(validationLoss),
492
+
493
  // Metadata for debugging
494
  _meta: {
495
  totalSteps,
 
498
  quality: quality.score
499
  }
500
  };
501
+
502
  // Debug: Check for NaN values
503
  const hasNaN = (arr, name) => {
504
  const nanCount = arr.filter(v => !Number.isFinite(v)).length;
 
506
  console.warn(`⚠️ Found ${nanCount} NaN values in ${name}`);
507
  }
508
  };
509
+
510
  if (totalSteps > 1000) { // Only debug large datasets
511
  hasNaN(result.accTrain, 'accTrain');
512
  hasNaN(result.lossTrain, 'lossTrain');
513
  hasNaN(result.accVal, 'accVal');
514
  hasNaN(result.lossVal, 'lossVal');
515
  }
516
+
517
  return result;
518
  }
519
 
 
527
  function generateLossCurveOptimized(sampledSteps, totalSteps, initialLoss, targetLoss, learningPhases, quality) {
528
  let learningRate = Random.learningRate();
529
  const loss = [];
530
+
531
  // Create a mapping function from sampled steps to values
532
  sampledSteps.forEach((step, index) => {
533
  // Find which learning phase this step belongs to
 
538
  break;
539
  }
540
  }
541
+
542
  const phaseStart = learningPhases[phaseIndex];
543
  const phaseEnd = learningPhases[phaseIndex + 1] || totalSteps;
544
  const phaseProgress = (step - phaseStart) / Math.max(1, phaseEnd - phaseStart);
545
  const phaseTarget = targetLoss * Math.pow(0.85, phaseIndex);
546
+
547
  // Exponential decay with phase blending
548
  let value = initialLoss * Math.exp(-learningRate * (step / totalSteps) * 100);
549
  value = 0.6 * value + 0.4 * (initialLoss + (phaseTarget - initialLoss) * (phaseIndex + phaseProgress) / Math.max(1, learningPhases.length - 1));
550
+
551
  // Add realistic noise that decreases over time
552
  const noiseGen = Random.noiseAmplitude(TrainingConfig.LOSS.NOISE_FACTOR * initialLoss);
553
  value += noiseGen(step / totalSteps);
554
+
555
  // Occasional loss spikes (common in training)
556
  if (Math.random() < TrainingConfig.LOSS.SPIKE_PROBABILITY) {
557
  value += TrainingConfig.LOSS.SPIKE_AMPLITUDE * initialLoss;
558
  }
559
+
560
  // Ensure no NaN values
561
  const finalValue = Math.max(0, Number.isFinite(value) ? value : initialLoss * 0.1);
562
  loss.push({ step, value: finalValue });
563
  });
564
+
565
  return loss;
566
  }
567
 
 
572
  const initialAccuracy = Random.between(TrainingConfig.ACCURACY.INITIAL_MIN, TrainingConfig.ACCURACY.INITIAL_MAX);
573
  let learningRate = Random.learningRate();
574
  const accuracy = [];
575
+
576
  sampledSteps.forEach((step, index) => {
577
  // Asymptotic growth towards target accuracy
578
  let value = targetAccuracy - (targetAccuracy - initialAccuracy) * Math.exp(-learningRate * (step / totalSteps) * 100);
579
+
580
  // Add realistic noise that decreases over time
581
  const noiseGen = Random.noiseAmplitude(TrainingConfig.ACCURACY.NOISE_AMPLITUDE);
582
  value += noiseGen(step / totalSteps);
583
+
584
  // Ensure no NaN values
585
  const finalValue = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0.1;
586
  accuracy.push({ step, value: finalValue });
587
+
588
  // Accelerate learning at phase boundaries
589
  if (learningPhases.includes(step)) {
590
  learningRate *= TrainingConfig.ACCURACY.PHASE_ACCELERATION;
591
  }
592
  });
593
+
594
  return accuracy;
595
  }
596
 
 
600
  function applyOverfittingOptimized(trainCurve, sampledSteps, totalSteps, quality) {
601
  const validationCurve = [];
602
  const gapConfig = TrainingConfig.VALIDATION_GAP;
603
+
604
  // Calculate when overfitting starts
605
  const overfittingStart = Math.floor(
606
+ (quality.isGood ? TrainingConfig.OVERFITTING.START_RATIO_GOOD : TrainingConfig.OVERFITTING.START_RATIO_POOR)
607
  * totalSteps + Random.between(-TrainingConfig.OVERFITTING.RANDOMNESS, TrainingConfig.OVERFITTING.RANDOMNESS) * totalSteps
608
  );
609
+
610
  const clampedStart = Math.max(Math.floor(0.5 * totalSteps), Math.min(Math.floor(0.95 * totalSteps), overfittingStart));
611
+
612
  trainCurve.forEach((trainPoint, index) => {
613
  const step = trainPoint.step;
614
  const isAccuracy = trainPoint.value <= 1; // Simple heuristic
615
+ const baseGap = isAccuracy
616
  ? Random.between(gapConfig.ACCURACY_MIN, gapConfig.ACCURACY_MAX)
617
  : Random.between(gapConfig.LOSS_MIN, gapConfig.LOSS_MAX);
618
+
619
+ let validationValue = isAccuracy
620
+ ? trainPoint.value - baseGap + Random.between(-gapConfig.FLUCTUATION / 2, gapConfig.FLUCTUATION / 2)
621
  : trainPoint.value * (1 + baseGap) + Random.between(-0.1, 0.1);
622
+
623
  // Apply overfitting effects after the overfitting point
624
  if (step >= clampedStart && !quality.isPoor) {
625
  const overfittingProgress = (step - clampedStart) / Math.max(1, totalSteps - clampedStart);
626
+
627
  if (isAccuracy) {
628
  validationValue -= TrainingConfig.OVERFITTING.ACCURACY_DEGRADATION * overfittingProgress;
629
  } else {
630
  validationValue += TrainingConfig.OVERFITTING.LOSS_INCREASE * overfittingProgress * trainPoint.value;
631
  }
632
  }
633
+
634
  // Ensure no NaN values in validation curves
635
+ const finalValue = Number.isFinite(validationValue)
636
  ? (isAccuracy ? Math.max(0, Math.min(1, validationValue)) : Math.max(0, validationValue))
637
  : (isAccuracy ? 0.1 : trainPoint.value);
638
+
639
  validationCurve.push({
640
  step,
641
  value: finalValue
642
  });
643
  });
644
+
645
  return validationCurve;
646
  }
app/src/components/trackio/renderers/ChartRendererRefactored.svelte CHANGED
@@ -1,45 +1,50 @@
1
  <script>
2
- import { onMount, onDestroy } from 'svelte';
3
- import { SVGManager } from './core/svg-manager.js';
4
- import { GridRenderer } from './core/grid-renderer.js';
5
- import { PathRenderer } from './core/path-renderer.js';
6
- import { InteractionManager } from './core/interaction-manager.js';
7
- import { ChartTransforms } from './utils/chart-transforms.js';
8
- import { trackioSampler } from '../core/adaptive-sampler.js';
9
-
 
 
10
  // Props - same as original ChartRenderer
11
  export let metricData = {};
12
  export let rawMetricData = {};
13
- export let colorForRun = (name) => '#999';
14
- export let variant = 'classic';
15
  export let logScaleX = false;
16
  export let smoothing = false;
17
  export let normalizeLoss = true;
18
- export let metricKey = '';
19
- export let titleText = '';
20
  export let hostEl = null;
21
  export let width = 800;
22
  export let height = 150;
23
  export let margin = { top: 10, right: 12, bottom: 46, left: 44 };
24
  export let onHover = null;
25
  export let onLeave = null;
26
-
 
 
27
  // Internal state
28
  let container;
29
  let svgManager;
30
  let gridRenderer;
31
  let pathRenderer;
32
  let interactionManager;
 
33
  let cleanup;
34
-
35
  // Sampling state
36
  let sampledData = {};
37
  let samplingInfo = {};
38
  let needsSampling = false;
39
-
40
  // Computed values
41
  $: innerHeight = height - margin.top - margin.bottom;
42
-
43
  // Reactive rendering when data or props change
44
  $: {
45
  if (container && svgManager) {
@@ -53,45 +58,86 @@
53
  render();
54
  }
55
  }
56
-
57
  /**
58
  * Initialize all managers and renderers
59
  */
60
  function initializeManagers() {
61
  if (!container) return;
62
-
63
  // Create SVG manager with configuration
64
  svgManager = new SVGManager(container, { width, height, margin });
65
  svgManager.ensureSvg();
66
  svgManager.initializeScales(logScaleX);
67
-
68
  // Create specialized renderers
69
  gridRenderer = new GridRenderer(svgManager);
70
  pathRenderer = new PathRenderer(svgManager);
71
  interactionManager = new InteractionManager(svgManager, pathRenderer);
72
-
73
- console.log('📊 Chart managers initialized');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  }
75
-
76
  /**
77
  * Apply adaptive sampling to large datasets
78
  */
79
  function applySampling() {
80
  // Check if any run has more than 400 points
81
- const runSizes = Object.keys(metricData).map(run => (metricData[run] || []).length);
 
 
82
  const maxSize = Math.max(0, ...runSizes);
83
  needsSampling = maxSize > 400;
84
-
85
  if (needsSampling) {
86
- console.log(`🎯 Large dataset detected (${maxSize} points), applying adaptive sampling`);
87
- const result = trackioSampler.sampleMetricData(metricData, 'smart');
 
 
88
  sampledData = result.sampledData;
89
  samplingInfo = result.samplingInfo;
90
-
91
  // Log sampling stats
92
- Object.keys(samplingInfo).forEach(run => {
93
  const info = samplingInfo[run];
94
- console.log(`📊 ${run}: ${info.originalLength} → ${info.sampledLength} points (${(info.compressionRatio * 100).toFixed(1)}% retained)`);
 
 
95
  });
96
  } else {
97
  sampledData = metricData;
@@ -99,93 +145,219 @@
99
  }
100
  }
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  /**
103
  * Main render function - orchestrates all rendering
104
  */
105
  function render() {
106
  if (!svgManager) return;
107
-
108
  // Apply sampling if needed
109
  applySampling();
110
-
111
  // Use sampled data for rendering
112
  const dataToRender = needsSampling ? sampledData : metricData;
113
-
114
  // Validate and clean data
115
  const cleanedData = ChartTransforms.validateData(dataToRender);
116
- const processedData = ChartTransforms.processMetricData(cleanedData, metricKey, normalizeLoss);
117
-
 
 
 
 
118
  if (!processedData.hasData) {
119
  const { root } = svgManager.getGroups();
120
- root.style('display', 'none');
121
  return;
122
  }
123
-
124
  const { root } = svgManager.getGroups();
125
- root.style('display', null);
126
-
127
  // Update scales based on log scale setting
128
  svgManager.initializeScales(logScaleX);
129
-
130
  // Setup scales and domains
131
- const { stepIndex } = ChartTransforms.setupScales(svgManager, processedData, logScaleX);
132
- const normalizeY = ChartTransforms.createNormalizeFunction(processedData, normalizeLoss);
133
-
 
 
 
 
 
 
 
134
  // Update lineGen with normalization
135
  const { line: lineGen, y: yScale } = svgManager.getScales();
136
- lineGen.y(d => yScale(normalizeY(d.value)));
137
-
138
  // Update layout and render axes
139
- const { innerWidth, xTicksForced, yTicksForced } = svgManager.updateLayout(processedData.hoverSteps, logScaleX);
140
-
 
 
 
 
 
 
 
 
 
141
  // Render grid
142
- gridRenderer.renderGrid(xTicksForced, yTicksForced, processedData.hoverSteps, variant);
143
-
 
 
 
 
 
144
  // Render data series
145
  pathRenderer.renderSeries(
146
- processedData.runs,
147
- cleanedData,
148
- rawMetricData,
149
- colorForRun,
150
- smoothing,
151
- logScaleX,
152
- stepIndex,
153
- normalizeY
154
  );
155
-
156
  // Setup interactions
157
  interactionManager.setupHoverInteractions(
158
  processedData.hoverSteps,
159
  stepIndex,
160
- processedData.runs.map(r => ({
161
- run: r,
162
- color: colorForRun(r),
163
- values: (cleanedData[r] || []).slice().sort((a, b) => a.step - b.step)
164
  })),
165
  normalizeY,
166
  processedData.isAccuracy,
167
  innerWidth,
168
  logScaleX,
169
  onHover,
170
- onLeave
171
  );
172
  }
173
-
174
  /**
175
  * Public API: Show hover line at specific step
176
  */
177
  export function showHoverLine(step) {
178
  if (!interactionManager) return;
179
-
180
  // Use sampled data for interactions as well
181
  const dataToRender = needsSampling ? sampledData : metricData;
182
  const cleanedData = ChartTransforms.validateData(dataToRender);
183
- const processedData = ChartTransforms.processMetricData(cleanedData, metricKey, normalizeLoss);
184
- const { stepIndex } = ChartTransforms.setupScales(svgManager, processedData, logScaleX);
185
-
186
- interactionManager.showHoverLine(step, processedData.hoverSteps, stepIndex, logScaleX);
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  }
188
-
189
  /**
190
  * Public API: Hide hover line
191
  */
@@ -194,14 +366,30 @@
194
  interactionManager.hideHoverLine();
195
  }
196
  }
197
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  /**
199
  * Setup resize observer and lifecycle
200
  */
201
  onMount(() => {
202
  initializeManagers();
203
  render();
204
-
205
  // Debounced resize handling for better mobile performance
206
  let resizeTimeout;
207
  const debouncedRender = () => {
@@ -210,33 +398,39 @@
210
  render();
211
  }, 100);
212
  };
213
-
214
- const ro = window.ResizeObserver ? new ResizeObserver(debouncedRender) : null;
 
 
215
  if (ro && container) ro.observe(container);
216
-
217
  // Listen for orientation changes on mobile
218
  const handleOrientationChange = () => {
219
  setTimeout(() => {
220
  render();
221
  }, 300);
222
  };
223
-
224
- window.addEventListener('orientationchange', handleOrientationChange);
225
- window.addEventListener('resize', debouncedRender);
226
-
227
- cleanup = () => {
228
  if (ro) ro.disconnect();
229
  if (resizeTimeout) clearTimeout(resizeTimeout);
230
- window.removeEventListener('orientationchange', handleOrientationChange);
231
- window.removeEventListener('resize', debouncedRender);
232
  if (svgManager) svgManager.destroy();
233
  if (interactionManager) interactionManager.destroy();
 
234
  };
235
  });
236
-
237
  onDestroy(() => {
238
  cleanup && cleanup();
239
  });
240
  </script>
241
 
242
- <div bind:this={container} style="width: 100%; height: 100%; min-width: 200px; overflow: hidden;"></div>
 
 
 
 
1
  <script>
2
+ import { onMount, onDestroy } from "svelte";
3
+ import * as d3 from "d3";
4
+ import { SVGManager } from "./core/svg-manager.js";
5
+ import { GridRenderer } from "./core/grid-renderer.js";
6
+ import { PathRenderer } from "./core/path-renderer.js";
7
+ import { InteractionManager } from "./core/interaction-manager.js";
8
+ import { ZoomManager } from "./core/zoom-manager.js";
9
+ import { ChartTransforms } from "./utils/chart-transforms.js";
10
+ import { trackioSampler } from "../core/adaptive-sampler.js";
11
+
12
  // Props - same as original ChartRenderer
13
  export let metricData = {};
14
  export let rawMetricData = {};
15
+ export let colorForRun = (name) => "#999";
16
+ export let variant = "classic";
17
  export let logScaleX = false;
18
  export let smoothing = false;
19
  export let normalizeLoss = true;
20
+ export let metricKey = "";
21
+ export let titleText = "";
22
  export let hostEl = null;
23
  export let width = 800;
24
  export let height = 150;
25
  export let margin = { top: 10, right: 12, bottom: 46, left: 44 };
26
  export let onHover = null;
27
  export let onLeave = null;
28
+ export let enableZoom = true; // NEW: Enable zoom/pan
29
+ export let onZoomChange = null; // NEW: Callback when zoom state changes
30
+
31
  // Internal state
32
  let container;
33
  let svgManager;
34
  let gridRenderer;
35
  let pathRenderer;
36
  let interactionManager;
37
+ let zoomManager; // NEW
38
  let cleanup;
39
+
40
  // Sampling state
41
  let sampledData = {};
42
  let samplingInfo = {};
43
  let needsSampling = false;
44
+
45
  // Computed values
46
  $: innerHeight = height - margin.top - margin.bottom;
47
+
48
  // Reactive rendering when data or props change
49
  $: {
50
  if (container && svgManager) {
 
58
  render();
59
  }
60
  }
61
+
62
  /**
63
  * Initialize all managers and renderers
64
  */
65
  function initializeManagers() {
66
  if (!container) return;
67
+
68
  // Create SVG manager with configuration
69
  svgManager = new SVGManager(container, { width, height, margin });
70
  svgManager.ensureSvg();
71
  svgManager.initializeScales(logScaleX);
72
+
73
  // Create specialized renderers
74
  gridRenderer = new GridRenderer(svgManager);
75
  pathRenderer = new PathRenderer(svgManager);
76
  interactionManager = new InteractionManager(svgManager, pathRenderer);
77
+
78
+ // Create zoom manager
79
+ if (enableZoom) {
80
+ zoomManager = new ZoomManager(svgManager, {
81
+ zoomExtent: [1.0, 8.0],
82
+ enableX: true,
83
+ enableY: true,
84
+ });
85
+
86
+ zoomManager.initialize();
87
+
88
+ // Share zoom overlay with interaction manager
89
+ interactionManager.setExternalOverlay(zoomManager.getOverlay());
90
+
91
+ // Setup zoom callback
92
+ zoomManager.on("zoom", ({ xScale, yScale, hasMoved }) => {
93
+ renderWithZoomedScales(xScale, yScale);
94
+
95
+ if (onZoomChange) {
96
+ onZoomChange({ hasMoved, state: zoomManager.getState() });
97
+ }
98
+ });
99
+
100
+ // Hide tooltips during zoom start
101
+ zoomManager.on("zoomStart", () => {
102
+ if (interactionManager) {
103
+ interactionManager.hideHoverLine();
104
+ }
105
+ if (onLeave) {
106
+ onLeave();
107
+ }
108
+ });
109
+
110
+ console.log("🔍 ZoomManager initialized");
111
+ }
112
+
113
+ console.log("📊 Chart managers initialized");
114
  }
115
+
116
  /**
117
  * Apply adaptive sampling to large datasets
118
  */
119
  function applySampling() {
120
  // Check if any run has more than 400 points
121
+ const runSizes = Object.keys(metricData).map(
122
+ (run) => (metricData[run] || []).length,
123
+ );
124
  const maxSize = Math.max(0, ...runSizes);
125
  needsSampling = maxSize > 400;
126
+
127
  if (needsSampling) {
128
+ console.log(
129
+ `🎯 Large dataset detected (${maxSize} points), applying adaptive sampling`,
130
+ );
131
+ const result = trackioSampler.sampleMetricData(metricData, "smart");
132
  sampledData = result.sampledData;
133
  samplingInfo = result.samplingInfo;
134
+
135
  // Log sampling stats
136
+ Object.keys(samplingInfo).forEach((run) => {
137
  const info = samplingInfo[run];
138
+ console.log(
139
+ `📊 ${run}: ${info.originalLength} → ${info.sampledLength} points (${(info.compressionRatio * 100).toFixed(1)}% retained)`,
140
+ );
141
  });
142
  } else {
143
  sampledData = metricData;
 
145
  }
146
  }
147
 
148
+ /**
149
+ * Render with zoomed scales (called by ZoomManager)
150
+ */
151
+ function renderWithZoomedScales(zoomedXScale, zoomedYScale) {
152
+ if (!svgManager || !gridRenderer || !pathRenderer) return;
153
+
154
+ const dataToRender = needsSampling ? sampledData : metricData;
155
+ const cleanedData = ChartTransforms.validateData(dataToRender);
156
+ const processedData = ChartTransforms.processMetricData(
157
+ cleanedData,
158
+ metricKey,
159
+ normalizeLoss,
160
+ );
161
+
162
+ if (!processedData.hasData) return;
163
+
164
+ const { stepIndex } = ChartTransforms.setupScales(
165
+ svgManager,
166
+ processedData,
167
+ logScaleX,
168
+ );
169
+ const normalizeY = ChartTransforms.createNormalizeFunction(
170
+ processedData,
171
+ normalizeLoss,
172
+ );
173
+
174
+ // Get original scales for comparison
175
+ const { x: originalXScale, y: originalYScale } = svgManager.getScales();
176
+ const { innerWidth } = svgManager.calculateDimensions();
177
+
178
+ // Update axes with zoomed scales
179
+ const { axes: gAxes, grid: gGrid } = svgManager.getGroups();
180
+ const xTicksForced = zoomedXScale.ticks(Math.min(6, 10));
181
+ const yTicksForced = zoomedYScale.ticks(Math.min(6, 10));
182
+
183
+ // Redraw grid with zoomed Y scale
184
+ gGrid
185
+ .selectAll("line")
186
+ .data(yTicksForced)
187
+ .join("line")
188
+ .attr("x1", 0)
189
+ .attr("x2", innerWidth)
190
+ .attr("y1", (d) => zoomedYScale(d))
191
+ .attr("y2", (d) => zoomedYScale(d))
192
+ .attr("stroke", "var(--trackio-chart-grid-stroke)")
193
+ .attr("stroke-opacity", "var(--trackio-chart-grid-opacity)");
194
+
195
+ // Update axes
196
+ const formatAbbrev = (v) => {
197
+ if (Math.abs(v) >= 1e9) return (v / 1e9).toFixed(1) + "B";
198
+ if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(1) + "M";
199
+ if (Math.abs(v) >= 1e3) return (v / 1e3).toFixed(1) + "k";
200
+ return v.toFixed(2);
201
+ };
202
+
203
+ gAxes
204
+ .select(".x-axis")
205
+ .call(
206
+ d3
207
+ .axisBottom(zoomedXScale)
208
+ .tickValues(xTicksForced)
209
+ .tickFormat(formatAbbrev),
210
+ );
211
+
212
+ gAxes
213
+ .select(".y-axis")
214
+ .call(
215
+ d3
216
+ .axisLeft(zoomedYScale)
217
+ .tickValues(yTicksForced)
218
+ .tickFormat(formatAbbrev),
219
+ );
220
+
221
+ // Redraw paths with zoomed scales
222
+ pathRenderer.renderSeriesWithCustomScales(
223
+ processedData.runs,
224
+ cleanedData,
225
+ rawMetricData,
226
+ colorForRun,
227
+ smoothing,
228
+ logScaleX,
229
+ stepIndex,
230
+ normalizeY,
231
+ zoomedXScale,
232
+ zoomedYScale,
233
+ );
234
+ }
235
+
236
  /**
237
  * Main render function - orchestrates all rendering
238
  */
239
  function render() {
240
  if (!svgManager) return;
241
+
242
  // Apply sampling if needed
243
  applySampling();
244
+
245
  // Use sampled data for rendering
246
  const dataToRender = needsSampling ? sampledData : metricData;
247
+
248
  // Validate and clean data
249
  const cleanedData = ChartTransforms.validateData(dataToRender);
250
+ const processedData = ChartTransforms.processMetricData(
251
+ cleanedData,
252
+ metricKey,
253
+ normalizeLoss,
254
+ );
255
+
256
  if (!processedData.hasData) {
257
  const { root } = svgManager.getGroups();
258
+ root.style("display", "none");
259
  return;
260
  }
261
+
262
  const { root } = svgManager.getGroups();
263
+ root.style("display", null);
264
+
265
  // Update scales based on log scale setting
266
  svgManager.initializeScales(logScaleX);
267
+
268
  // Setup scales and domains
269
+ const { stepIndex } = ChartTransforms.setupScales(
270
+ svgManager,
271
+ processedData,
272
+ logScaleX,
273
+ );
274
+ const normalizeY = ChartTransforms.createNormalizeFunction(
275
+ processedData,
276
+ normalizeLoss,
277
+ );
278
+
279
  // Update lineGen with normalization
280
  const { line: lineGen, y: yScale } = svgManager.getScales();
281
+ lineGen.y((d) => yScale(normalizeY(d.value)));
282
+
283
  // Update layout and render axes
284
+ const { innerWidth, xTicksForced, yTicksForced } = svgManager.updateLayout(
285
+ processedData.hoverSteps,
286
+ logScaleX,
287
+ );
288
+
289
+ // Update zoom layout if enabled
290
+ if (zoomManager) {
291
+ const { innerHeight } = svgManager.calculateDimensions();
292
+ zoomManager.updateLayout(innerWidth, innerHeight);
293
+ }
294
+
295
  // Render grid
296
+ gridRenderer.renderGrid(
297
+ xTicksForced,
298
+ yTicksForced,
299
+ processedData.hoverSteps,
300
+ variant,
301
+ );
302
+
303
  // Render data series
304
  pathRenderer.renderSeries(
305
+ processedData.runs,
306
+ cleanedData,
307
+ rawMetricData,
308
+ colorForRun,
309
+ smoothing,
310
+ logScaleX,
311
+ stepIndex,
312
+ normalizeY,
313
  );
314
+
315
  // Setup interactions
316
  interactionManager.setupHoverInteractions(
317
  processedData.hoverSteps,
318
  stepIndex,
319
+ processedData.runs.map((r) => ({
320
+ run: r,
321
+ color: colorForRun(r),
322
+ values: (cleanedData[r] || []).slice().sort((a, b) => a.step - b.step),
323
  })),
324
  normalizeY,
325
  processedData.isAccuracy,
326
  innerWidth,
327
  logScaleX,
328
  onHover,
329
+ onLeave,
330
  );
331
  }
332
+
333
  /**
334
  * Public API: Show hover line at specific step
335
  */
336
  export function showHoverLine(step) {
337
  if (!interactionManager) return;
338
+
339
  // Use sampled data for interactions as well
340
  const dataToRender = needsSampling ? sampledData : metricData;
341
  const cleanedData = ChartTransforms.validateData(dataToRender);
342
+ const processedData = ChartTransforms.processMetricData(
343
+ cleanedData,
344
+ metricKey,
345
+ normalizeLoss,
346
+ );
347
+ const { stepIndex } = ChartTransforms.setupScales(
348
+ svgManager,
349
+ processedData,
350
+ logScaleX,
351
+ );
352
+
353
+ interactionManager.showHoverLine(
354
+ step,
355
+ processedData.hoverSteps,
356
+ stepIndex,
357
+ logScaleX,
358
+ );
359
  }
360
+
361
  /**
362
  * Public API: Hide hover line
363
  */
 
366
  interactionManager.hideHoverLine();
367
  }
368
  }
369
+
370
+ /**
371
+ * Public API: Reset zoom to initial state
372
+ */
373
+ export function resetZoom(animated = true) {
374
+ if (zoomManager) {
375
+ zoomManager.reset(animated);
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Public API: Get zoom state
381
+ */
382
+ export function getZoomState() {
383
+ return zoomManager ? zoomManager.getState() : null;
384
+ }
385
+
386
  /**
387
  * Setup resize observer and lifecycle
388
  */
389
  onMount(() => {
390
  initializeManagers();
391
  render();
392
+
393
  // Debounced resize handling for better mobile performance
394
  let resizeTimeout;
395
  const debouncedRender = () => {
 
398
  render();
399
  }, 100);
400
  };
401
+
402
+ const ro = window.ResizeObserver
403
+ ? new ResizeObserver(debouncedRender)
404
+ : null;
405
  if (ro && container) ro.observe(container);
406
+
407
  // Listen for orientation changes on mobile
408
  const handleOrientationChange = () => {
409
  setTimeout(() => {
410
  render();
411
  }, 300);
412
  };
413
+
414
+ window.addEventListener("orientationchange", handleOrientationChange);
415
+ window.addEventListener("resize", debouncedRender);
416
+
417
+ cleanup = () => {
418
  if (ro) ro.disconnect();
419
  if (resizeTimeout) clearTimeout(resizeTimeout);
420
+ window.removeEventListener("orientationchange", handleOrientationChange);
421
+ window.removeEventListener("resize", debouncedRender);
422
  if (svgManager) svgManager.destroy();
423
  if (interactionManager) interactionManager.destroy();
424
+ if (zoomManager) zoomManager.destroy();
425
  };
426
  });
427
+
428
  onDestroy(() => {
429
  cleanup && cleanup();
430
  });
431
  </script>
432
 
433
+ <div
434
+ bind:this={container}
435
+ style="width: 100%; height: 100%; min-width: 200px; overflow: hidden;"
436
+ ></div>
app/src/components/trackio/renderers/core/interaction-manager.js CHANGED
@@ -10,39 +10,55 @@ export class InteractionManager {
10
  this.pathRenderer = pathRenderer;
11
  this.hoverLine = null;
12
  this.hideTipTimer = null;
13
-
 
14
  // Performance optimization for large datasets
15
  this.lastHoverTime = 0;
16
  this.hoverThrottleMs = 16; // ~60fps max hover rate
17
  this.lastNearestStep = null;
18
  }
19
 
 
 
 
 
 
 
 
 
20
  /**
21
  * Setup hover interactions for the chart
22
  */
23
  setupHoverInteractions(hoverSteps, stepIndex, series, normalizeY, isAccuracy, innerWidth, logScaleX, onHover, onLeave) {
24
  const { hover: gHover } = this.svgManager.getGroups();
25
  const { x: xScale, y: yScale } = this.svgManager.getScales();
26
-
27
  if (!gHover || !this.svgManager.container) return;
28
-
29
- gHover.selectAll('*').remove();
30
-
31
  // Calculate dimensions
32
  const { innerWidth: currentInnerWidth, innerHeight: currentInnerHeight } = this.svgManager.calculateDimensions();
33
  const actualInnerWidth = innerWidth || currentInnerWidth;
34
  const actualInnerHeight = currentInnerHeight;
35
-
36
- // Create interaction overlay
37
- const overlay = gHover.append('rect')
38
- .attr('fill', 'transparent')
39
- .style('cursor', 'crosshair')
40
- .attr('x', 0)
41
- .attr('y', 0)
42
- .attr('width', actualInnerWidth)
43
- .attr('height', actualInnerHeight)
44
- .style('pointer-events', 'all');
45
-
 
 
 
 
 
 
 
46
  // Create hover line
47
  this.hoverLine = gHover.append('line')
48
  .style('stroke', 'var(--text-color)')
@@ -51,44 +67,44 @@ export class InteractionManager {
51
  .attr('y1', 0)
52
  .attr('y2', actualInnerHeight)
53
  .style('display', 'none')
54
- .style('pointer-events', 'none');
55
-
56
  // Mouse move handler with throttling for performance
57
- const onMove = (ev) => {
58
  try {
59
  // Throttle hover events for large datasets
60
  const now = performance.now();
61
  const isLargeDataset = hoverSteps.length > 400;
62
-
63
  if (isLargeDataset && (now - this.lastHoverTime) < this.hoverThrottleMs) {
64
  return; // Skip this hover event
65
  }
66
  this.lastHoverTime = now;
67
-
68
- if (this.hideTipTimer) {
69
- clearTimeout(this.hideTipTimer);
70
- this.hideTipTimer = null;
71
- }
72
-
73
  const [mx, my] = d3.pointer(ev, overlay.node());
74
  const globalX = ev.clientX;
75
- const globalY = ev.clientY;
76
-
77
  // Find nearest step
78
  const { nearest, xpx } = this.findNearestStep(mx, hoverSteps, stepIndex, logScaleX, xScale);
79
-
80
  // Skip if same step as last time (avoid redundant updates)
81
  if (this.lastNearestStep === nearest) {
82
  return;
83
  }
84
  this.lastNearestStep = nearest;
85
-
86
  // Update hover line
87
- this.hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
88
-
89
  // Prepare hover data
90
  const entries = this.prepareHoverData(series, nearest, normalizeY, isAccuracy);
91
-
92
  // Call parent hover callback
93
  if (onHover && entries.length > 0) {
94
  onHover({
@@ -97,25 +113,25 @@ export class InteractionManager {
97
  position: { x: mx, y: my, globalX, globalY }
98
  });
99
  }
100
-
101
  // Update point visibility
102
  this.pathRenderer.updatePointVisibility(nearest);
103
-
104
- } catch(error) {
105
  console.error('Error in hover interaction:', error);
106
  }
107
  };
108
-
109
  // Mouse leave handler
110
- const onMouseLeave = () => {
111
  this.lastNearestStep = null; // Reset cache
112
- this.hideTipTimer = setTimeout(() => {
113
- this.hoverLine.style('display', 'none');
114
  if (onLeave) onLeave();
115
  this.pathRenderer.hideAllPoints();
116
- }, 0);
117
  };
118
-
119
  // Attach event listeners
120
  overlay.on('mousemove', onMove).on('mouseleave', onMouseLeave);
121
  }
@@ -125,17 +141,17 @@ export class InteractionManager {
125
  */
126
  findNearestStep(mx, hoverSteps, stepIndex, logScaleX, xScale) {
127
  let nearest, xpx;
128
-
129
  if (logScaleX) {
130
  const mouseStepValue = xScale.invert(mx);
131
-
132
  // For large datasets, use binary search instead of linear search
133
  if (hoverSteps.length > 400) {
134
  nearest = this.binarySearchClosest(hoverSteps, mouseStepValue);
135
  } else {
136
  let minDist = Infinity;
137
  let closestStep = hoverSteps[0];
138
-
139
  hoverSteps.forEach(step => {
140
  const dist = Math.abs(Math.log(step) - Math.log(mouseStepValue));
141
  if (dist < minDist) {
@@ -143,17 +159,17 @@ export class InteractionManager {
143
  closestStep = step;
144
  }
145
  });
146
-
147
  nearest = closestStep;
148
  }
149
-
150
  xpx = xScale(nearest);
151
  } else {
152
- const idx = Math.round(Math.max(0, Math.min(hoverSteps.length - 1, xScale.invert(mx))));
153
- nearest = hoverSteps[idx];
154
  xpx = xScale(idx);
155
  }
156
-
157
  return { nearest, xpx };
158
  }
159
 
@@ -163,48 +179,79 @@ export class InteractionManager {
163
  binarySearchClosest(sortedArray, target) {
164
  let left = 0;
165
  let right = sortedArray.length - 1;
166
-
167
  if (target <= sortedArray[left]) return sortedArray[left];
168
  if (target >= sortedArray[right]) return sortedArray[right];
169
-
170
  while (left <= right) {
171
  const mid = Math.floor((left + right) / 2);
172
  const midVal = sortedArray[mid];
173
-
174
  if (midVal === target) return midVal;
175
-
176
  if (midVal < target) {
177
  left = mid + 1;
178
  } else {
179
  right = mid - 1;
180
  }
181
  }
182
-
183
  // At this point, left > right
184
  // sortedArray[right] < target < sortedArray[left]
185
  const leftDist = Math.abs(sortedArray[left] - target);
186
  const rightDist = Math.abs(sortedArray[right] - target);
187
-
188
  return leftDist < rightDist ? sortedArray[left] : sortedArray[right];
189
  }
190
 
191
  /**
192
- * Prepare data for hover tooltip
193
  */
194
  prepareHoverData(series, nearestStep, normalizeY, isAccuracy) {
195
- const entries = series.map(s => {
196
- const m = new Map(s.values.map(v => [v.step, v]));
197
- const pt = m.get(nearestStep);
198
- return { run: s.run, color: s.color, pt };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  }).filter(e => e.pt && e.pt.value != null)
200
- .sort((a, b) => a.pt.value - b.pt.value);
201
-
202
- const fmt = (vv) => (isAccuracy ? (+vv).toFixed(4) : (+vv).toFixed(4));
203
-
204
- return entries.map(e => ({
205
- color: e.color,
206
- name: e.run,
207
- valueText: fmt(e.pt.value)
208
  }));
209
  }
210
 
@@ -213,9 +260,9 @@ export class InteractionManager {
213
  */
214
  showHoverLine(step, hoverSteps, stepIndex, logScaleX) {
215
  if (!this.hoverLine || !this.svgManager.getScales().x) return;
216
-
217
  const { x: xScale } = this.svgManager.getScales();
218
-
219
  try {
220
  let xpx;
221
  if (logScaleX) {
@@ -226,7 +273,7 @@ export class InteractionManager {
226
  xpx = xScale(stepIndexValue);
227
  }
228
  }
229
-
230
  if (xpx !== undefined) {
231
  this.hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
232
  }
 
10
  this.pathRenderer = pathRenderer;
11
  this.hoverLine = null;
12
  this.hideTipTimer = null;
13
+ this.externalOverlay = null; // For using zoom overlay
14
+
15
  // Performance optimization for large datasets
16
  this.lastHoverTime = 0;
17
  this.hoverThrottleMs = 16; // ~60fps max hover rate
18
  this.lastNearestStep = null;
19
  }
20
 
21
+ /**
22
+ * Set an external overlay to use instead of creating our own
23
+ * This is used when zoom is enabled to share the same overlay
24
+ */
25
+ setExternalOverlay(overlay) {
26
+ this.externalOverlay = overlay;
27
+ }
28
+
29
  /**
30
  * Setup hover interactions for the chart
31
  */
32
  setupHoverInteractions(hoverSteps, stepIndex, series, normalizeY, isAccuracy, innerWidth, logScaleX, onHover, onLeave) {
33
  const { hover: gHover } = this.svgManager.getGroups();
34
  const { x: xScale, y: yScale } = this.svgManager.getScales();
35
+
36
  if (!gHover || !this.svgManager.container) return;
37
+
38
+ gHover.selectAll('*').remove();
39
+
40
  // Calculate dimensions
41
  const { innerWidth: currentInnerWidth, innerHeight: currentInnerHeight } = this.svgManager.calculateDimensions();
42
  const actualInnerWidth = innerWidth || currentInnerWidth;
43
  const actualInnerHeight = currentInnerHeight;
44
+
45
+ // Use external overlay if provided (zoom overlay), otherwise create our own
46
+ let overlay;
47
+ if (this.externalOverlay) {
48
+ overlay = this.externalOverlay;
49
+ // Keep cursor as grab when using zoom overlay
50
+ } else {
51
+ // Create interaction overlay
52
+ overlay = gHover.append('rect')
53
+ .attr('fill', 'transparent')
54
+ .style('cursor', 'crosshair')
55
+ .attr('x', 0)
56
+ .attr('y', 0)
57
+ .attr('width', actualInnerWidth)
58
+ .attr('height', actualInnerHeight)
59
+ .style('pointer-events', 'all');
60
+ }
61
+
62
  // Create hover line
63
  this.hoverLine = gHover.append('line')
64
  .style('stroke', 'var(--text-color)')
 
67
  .attr('y1', 0)
68
  .attr('y2', actualInnerHeight)
69
  .style('display', 'none')
70
+ .style('pointer-events', 'none');
71
+
72
  // Mouse move handler with throttling for performance
73
+ const onMove = (ev) => {
74
  try {
75
  // Throttle hover events for large datasets
76
  const now = performance.now();
77
  const isLargeDataset = hoverSteps.length > 400;
78
+
79
  if (isLargeDataset && (now - this.lastHoverTime) < this.hoverThrottleMs) {
80
  return; // Skip this hover event
81
  }
82
  this.lastHoverTime = now;
83
+
84
+ if (this.hideTipTimer) {
85
+ clearTimeout(this.hideTipTimer);
86
+ this.hideTipTimer = null;
87
+ }
88
+
89
  const [mx, my] = d3.pointer(ev, overlay.node());
90
  const globalX = ev.clientX;
91
+ const globalY = ev.clientY;
92
+
93
  // Find nearest step
94
  const { nearest, xpx } = this.findNearestStep(mx, hoverSteps, stepIndex, logScaleX, xScale);
95
+
96
  // Skip if same step as last time (avoid redundant updates)
97
  if (this.lastNearestStep === nearest) {
98
  return;
99
  }
100
  this.lastNearestStep = nearest;
101
+
102
  // Update hover line
103
+ this.hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
104
+
105
  // Prepare hover data
106
  const entries = this.prepareHoverData(series, nearest, normalizeY, isAccuracy);
107
+
108
  // Call parent hover callback
109
  if (onHover && entries.length > 0) {
110
  onHover({
 
113
  position: { x: mx, y: my, globalX, globalY }
114
  });
115
  }
116
+
117
  // Update point visibility
118
  this.pathRenderer.updatePointVisibility(nearest);
119
+
120
+ } catch (error) {
121
  console.error('Error in hover interaction:', error);
122
  }
123
  };
124
+
125
  // Mouse leave handler
126
+ const onMouseLeave = () => {
127
  this.lastNearestStep = null; // Reset cache
128
+ this.hideTipTimer = setTimeout(() => {
129
+ this.hoverLine.style('display', 'none');
130
  if (onLeave) onLeave();
131
  this.pathRenderer.hideAllPoints();
132
+ }, 0);
133
  };
134
+
135
  // Attach event listeners
136
  overlay.on('mousemove', onMove).on('mouseleave', onMouseLeave);
137
  }
 
141
  */
142
  findNearestStep(mx, hoverSteps, stepIndex, logScaleX, xScale) {
143
  let nearest, xpx;
144
+
145
  if (logScaleX) {
146
  const mouseStepValue = xScale.invert(mx);
147
+
148
  // For large datasets, use binary search instead of linear search
149
  if (hoverSteps.length > 400) {
150
  nearest = this.binarySearchClosest(hoverSteps, mouseStepValue);
151
  } else {
152
  let minDist = Infinity;
153
  let closestStep = hoverSteps[0];
154
+
155
  hoverSteps.forEach(step => {
156
  const dist = Math.abs(Math.log(step) - Math.log(mouseStepValue));
157
  if (dist < minDist) {
 
159
  closestStep = step;
160
  }
161
  });
162
+
163
  nearest = closestStep;
164
  }
165
+
166
  xpx = xScale(nearest);
167
  } else {
168
+ const idx = Math.round(Math.max(0, Math.min(hoverSteps.length - 1, xScale.invert(mx))));
169
+ nearest = hoverSteps[idx];
170
  xpx = xScale(idx);
171
  }
172
+
173
  return { nearest, xpx };
174
  }
175
 
 
179
  binarySearchClosest(sortedArray, target) {
180
  let left = 0;
181
  let right = sortedArray.length - 1;
182
+
183
  if (target <= sortedArray[left]) return sortedArray[left];
184
  if (target >= sortedArray[right]) return sortedArray[right];
185
+
186
  while (left <= right) {
187
  const mid = Math.floor((left + right) / 2);
188
  const midVal = sortedArray[mid];
189
+
190
  if (midVal === target) return midVal;
191
+
192
  if (midVal < target) {
193
  left = mid + 1;
194
  } else {
195
  right = mid - 1;
196
  }
197
  }
198
+
199
  // At this point, left > right
200
  // sortedArray[right] < target < sortedArray[left]
201
  const leftDist = Math.abs(sortedArray[left] - target);
202
  const rightDist = Math.abs(sortedArray[right] - target);
203
+
204
  return leftDist < rightDist ? sortedArray[left] : sortedArray[right];
205
  }
206
 
207
  /**
208
+ * Prepare data for hover tooltip with interpolation for missing points
209
  */
210
  prepareHoverData(series, nearestStep, normalizeY, isAccuracy) {
211
+ const entries = series.map(s => {
212
+ const values = s.values.sort((a, b) => a.step - b.step);
213
+ const m = new Map(values.map(v => [v.step, v]));
214
+ let pt = m.get(nearestStep);
215
+
216
+ // If no exact point, interpolate from surrounding points
217
+ if (!pt) {
218
+ // Find the two closest points (one before, one after)
219
+ let before = null;
220
+ let after = null;
221
+
222
+ for (let i = 0; i < values.length; i++) {
223
+ if (values[i].step < nearestStep) {
224
+ before = values[i];
225
+ } else if (values[i].step > nearestStep && !after) {
226
+ after = values[i];
227
+ break;
228
+ }
229
+ }
230
+
231
+ // Interpolate if we have both surrounding points
232
+ if (before && after) {
233
+ const ratio = (nearestStep - before.step) / (after.step - before.step);
234
+ const interpolatedValue = before.value + ratio * (after.value - before.value);
235
+ pt = { step: nearestStep, value: interpolatedValue };
236
+ } else if (before) {
237
+ // Use the last known value
238
+ pt = before;
239
+ } else if (after) {
240
+ // Use the first known value
241
+ pt = after;
242
+ }
243
+ }
244
+
245
+ return { run: s.run, color: s.color, pt, hasExactPoint: !!m.get(nearestStep) };
246
  }).filter(e => e.pt && e.pt.value != null)
247
+ .sort((a, b) => a.pt.value - b.pt.value);
248
+
249
+ const fmt = (vv) => (isAccuracy ? (+vv).toFixed(4) : (+vv).toFixed(4));
250
+
251
+ return entries.map(e => ({
252
+ color: e.color,
253
+ name: e.run,
254
+ valueText: fmt(e.pt.value)
255
  }));
256
  }
257
 
 
260
  */
261
  showHoverLine(step, hoverSteps, stepIndex, logScaleX) {
262
  if (!this.hoverLine || !this.svgManager.getScales().x) return;
263
+
264
  const { x: xScale } = this.svgManager.getScales();
265
+
266
  try {
267
  let xpx;
268
  if (logScaleX) {
 
273
  xpx = xScale(stepIndexValue);
274
  }
275
  }
276
+
277
  if (xpx !== undefined) {
278
  this.hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
279
  }
app/src/components/trackio/renderers/core/path-renderer.js CHANGED
@@ -15,12 +15,12 @@ export class PathRenderer {
15
  renderSeries(runs, metricData, rawMetricData, colorForRun, smoothing, logScaleX, stepIndex, normalizeY) {
16
  const { lines: gLines, points: gPoints } = this.svgManager.getGroups();
17
  const { line: lineGen } = this.svgManager.getScales();
18
-
19
  // Prepare series data
20
- const series = runs.map(r => ({
21
- run: r,
22
- color: colorForRun(r),
23
- values: (metricData[r] || []).slice().sort((a, b) => a.step - b.step)
24
  }));
25
 
26
  // Render background lines for smoothing
@@ -41,15 +41,15 @@ export class PathRenderer {
41
  * Render raw data lines (background when smoothing is enabled)
42
  */
43
  renderRawLines(gLines, runs, rawMetricData, colorForRun, lineGen) {
44
- const rawSeries = runs.map(r => ({
45
- run: r,
46
- color: colorForRun(r),
47
- values: (rawMetricData[r] || []).slice().sort((a, b) => a.step - b.step)
48
  }));
49
-
50
  const rawPaths = gLines.selectAll('path.raw-line')
51
- .data(rawSeries, d => d.run + '-raw');
52
-
53
  // Enter
54
  rawPaths.enter()
55
  .append('path')
@@ -60,14 +60,14 @@ export class PathRenderer {
60
  .attr('opacity', 0.2)
61
  .attr('stroke', d => d.color)
62
  .style('pointer-events', 'none')
63
- .attr('d', d => lineGen(d.values));
64
-
65
  // Update
66
  rawPaths
67
  .attr('stroke', d => d.color)
68
  .attr('opacity', 0.2)
69
  .attr('d', d => lineGen(d.values));
70
-
71
  // Exit
72
  rawPaths.exit().remove();
73
  }
@@ -77,8 +77,8 @@ export class PathRenderer {
77
  */
78
  renderMainLines(gLines, series, lineGen) {
79
  const paths = gLines.selectAll('path.run-line')
80
- .data(series, d => d.run);
81
-
82
  // Enter
83
  paths.enter()
84
  .append('path')
@@ -89,15 +89,15 @@ export class PathRenderer {
89
  .attr('opacity', 0.9)
90
  .attr('stroke', d => d.color)
91
  .style('pointer-events', 'none')
92
- .attr('d', d => lineGen(d.values));
93
-
94
  // Update with transition
95
  paths.transition()
96
  .duration(160)
97
  .attr('stroke', d => d.color)
98
  .attr('opacity', 0.9)
99
  .attr('d', d => lineGen(d.values));
100
-
101
  // Exit
102
  paths.exit().remove();
103
  }
@@ -107,19 +107,19 @@ export class PathRenderer {
107
  */
108
  renderPoints(gPoints, series, logScaleX, stepIndex, normalizeY) {
109
  const { x: xScale, y: yScale } = this.svgManager.getScales();
110
-
111
- const allPoints = series.flatMap(s =>
112
- s.values.map(v => ({
113
- run: s.run,
114
- color: s.color,
115
- step: v.step,
116
- value: v.value
117
  }))
118
  );
119
-
120
  const ptsSel = gPoints.selectAll('circle.pt')
121
- .data(allPoints, d => `${d.run}-${d.step}`);
122
-
123
  // Enter
124
  ptsSel.enter()
125
  .append('circle')
@@ -134,8 +134,8 @@ export class PathRenderer {
134
  .attr('cy', d => yScale(normalizeY(d.value)))
135
  .merge(ptsSel)
136
  .attr('cx', d => logScaleX ? xScale(d.step) : xScale(stepIndex.get(d.step)))
137
- .attr('cy', d => yScale(normalizeY(d.value)));
138
-
139
  // Exit
140
  ptsSel.exit().remove();
141
  }
@@ -145,11 +145,11 @@ export class PathRenderer {
145
  */
146
  updatePointVisibility(nearestStep) {
147
  const { points: gPoints } = this.svgManager.getGroups();
148
-
149
- try {
150
  gPoints.selectAll('circle.pt')
151
- .attr('r', d => (d && d.step === nearestStep ? 4 : 0));
152
- } catch(_) {}
153
  }
154
 
155
  /**
@@ -157,9 +157,74 @@ export class PathRenderer {
157
  */
158
  hideAllPoints() {
159
  const { points: gPoints } = this.svgManager.getGroups();
160
-
161
- try {
162
- gPoints.selectAll('circle.pt').attr('r', 0);
163
- } catch(_) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  }
165
  }
 
15
  renderSeries(runs, metricData, rawMetricData, colorForRun, smoothing, logScaleX, stepIndex, normalizeY) {
16
  const { lines: gLines, points: gPoints } = this.svgManager.getGroups();
17
  const { line: lineGen } = this.svgManager.getScales();
18
+
19
  // Prepare series data
20
+ const series = runs.map(r => ({
21
+ run: r,
22
+ color: colorForRun(r),
23
+ values: (metricData[r] || []).slice().sort((a, b) => a.step - b.step)
24
  }));
25
 
26
  // Render background lines for smoothing
 
41
  * Render raw data lines (background when smoothing is enabled)
42
  */
43
  renderRawLines(gLines, runs, rawMetricData, colorForRun, lineGen) {
44
+ const rawSeries = runs.map(r => ({
45
+ run: r,
46
+ color: colorForRun(r),
47
+ values: (rawMetricData[r] || []).slice().sort((a, b) => a.step - b.step)
48
  }));
49
+
50
  const rawPaths = gLines.selectAll('path.raw-line')
51
+ .data(rawSeries, d => d.run + '-raw');
52
+
53
  // Enter
54
  rawPaths.enter()
55
  .append('path')
 
60
  .attr('opacity', 0.2)
61
  .attr('stroke', d => d.color)
62
  .style('pointer-events', 'none')
63
+ .attr('d', d => lineGen(d.values));
64
+
65
  // Update
66
  rawPaths
67
  .attr('stroke', d => d.color)
68
  .attr('opacity', 0.2)
69
  .attr('d', d => lineGen(d.values));
70
+
71
  // Exit
72
  rawPaths.exit().remove();
73
  }
 
77
  */
78
  renderMainLines(gLines, series, lineGen) {
79
  const paths = gLines.selectAll('path.run-line')
80
+ .data(series, d => d.run);
81
+
82
  // Enter
83
  paths.enter()
84
  .append('path')
 
89
  .attr('opacity', 0.9)
90
  .attr('stroke', d => d.color)
91
  .style('pointer-events', 'none')
92
+ .attr('d', d => lineGen(d.values));
93
+
94
  // Update with transition
95
  paths.transition()
96
  .duration(160)
97
  .attr('stroke', d => d.color)
98
  .attr('opacity', 0.9)
99
  .attr('d', d => lineGen(d.values));
100
+
101
  // Exit
102
  paths.exit().remove();
103
  }
 
107
  */
108
  renderPoints(gPoints, series, logScaleX, stepIndex, normalizeY) {
109
  const { x: xScale, y: yScale } = this.svgManager.getScales();
110
+
111
+ const allPoints = series.flatMap(s =>
112
+ s.values.map(v => ({
113
+ run: s.run,
114
+ color: s.color,
115
+ step: v.step,
116
+ value: v.value
117
  }))
118
  );
119
+
120
  const ptsSel = gPoints.selectAll('circle.pt')
121
+ .data(allPoints, d => `${d.run}-${d.step}`);
122
+
123
  // Enter
124
  ptsSel.enter()
125
  .append('circle')
 
134
  .attr('cy', d => yScale(normalizeY(d.value)))
135
  .merge(ptsSel)
136
  .attr('cx', d => logScaleX ? xScale(d.step) : xScale(stepIndex.get(d.step)))
137
+ .attr('cy', d => yScale(normalizeY(d.value)));
138
+
139
  // Exit
140
  ptsSel.exit().remove();
141
  }
 
145
  */
146
  updatePointVisibility(nearestStep) {
147
  const { points: gPoints } = this.svgManager.getGroups();
148
+
149
+ try {
150
  gPoints.selectAll('circle.pt')
151
+ .attr('r', d => (d && d.step === nearestStep ? 4 : 0));
152
+ } catch (_) { }
153
  }
154
 
155
  /**
 
157
  */
158
  hideAllPoints() {
159
  const { points: gPoints } = this.svgManager.getGroups();
160
+
161
+ try {
162
+ gPoints.selectAll('circle.pt').attr('r', 0);
163
+ } catch (_) { }
164
+ }
165
+
166
+ /**
167
+ * Render series with custom scales (for zoom)
168
+ * Similar to renderSeries but uses provided scales instead of svgManager's scales
169
+ */
170
+ renderSeriesWithCustomScales(runs, metricData, rawMetricData, colorForRun, smoothing, logScaleX, stepIndex, normalizeY, customXScale, customYScale) {
171
+ const { lines: gLines, points: gPoints } = this.svgManager.getGroups();
172
+
173
+ // Create custom line generator with zoomed scales
174
+ const customLineGen = d3.line()
175
+ .x(d => {
176
+ if (logScaleX) {
177
+ return customXScale(d.step);
178
+ } else {
179
+ const idx = stepIndex ? stepIndex.get(d.step) : 0;
180
+ return customXScale(idx);
181
+ }
182
+ })
183
+ .y(d => customYScale(normalizeY(d.value)));
184
+
185
+ // Prepare series data
186
+ const series = runs.map(r => ({
187
+ run: r,
188
+ color: colorForRun(r),
189
+ values: (metricData[r] || []).slice().sort((a, b) => a.step - b.step)
190
+ }));
191
+
192
+ // Update raw lines if smoothing is enabled
193
+ if (smoothing && rawMetricData && Object.keys(rawMetricData).length > 0) {
194
+ const rawSeries = runs.map(r => ({
195
+ run: r,
196
+ color: colorForRun(r),
197
+ values: (rawMetricData[r] || []).slice().sort((a, b) => a.step - b.step)
198
+ }));
199
+
200
+ gLines.selectAll('path.raw-line')
201
+ .data(rawSeries, d => d.run + '-raw')
202
+ .attr('d', d => customLineGen(d.values));
203
+ }
204
+
205
+ // Update main lines
206
+ gLines.selectAll('path.run-line')
207
+ .data(series, d => d.run)
208
+ .attr('d', d => customLineGen(d.values));
209
+
210
+ // Update points positions
211
+ const allPoints = [];
212
+ series.forEach(s => {
213
+ s.values.forEach(v => {
214
+ allPoints.push({ run: s.run, color: s.color, step: v.step, value: v.value });
215
+ });
216
+ });
217
+
218
+ gPoints.selectAll('circle.pt')
219
+ .data(allPoints, d => `${d.run}-${d.step}`)
220
+ .attr('cx', d => {
221
+ if (logScaleX) {
222
+ return customXScale(d.step);
223
+ } else {
224
+ const idx = stepIndex ? stepIndex.get(d.step) : 0;
225
+ return customXScale(idx);
226
+ }
227
+ })
228
+ .attr('cy', d => customYScale(normalizeY(d.value)));
229
  }
230
  }
app/src/components/trackio/renderers/core/zoom-manager.js ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Zoom & Pan Manager for TrackIO Charts
2
+ // Inspired by the d3-line-chart implementation
3
+
4
+ import * as d3 from 'd3';
5
+
6
+ /**
7
+ * ZoomManager - Handles zoom and pan interactions for charts
8
+ *
9
+ * Key principles:
10
+ * - Uses rescaleX/rescaleY instead of transforming the group
11
+ * - Redraws paths with new scales instead of CSS transforms
12
+ * - Keeps axes and grid outside the zoomed content
13
+ * - Uses clip-path to constrain the plot area
14
+ */
15
+ export class ZoomManager {
16
+ constructor(svgManager, options = {}) {
17
+ this.svgManager = svgManager;
18
+ this.options = {
19
+ zoomExtent: [1.0, 8.0], // Min and max zoom levels
20
+ enableX: true, // Enable X-axis zoom
21
+ enableY: true, // Enable Y-axis zoom
22
+ transitionDuration: 750, // Reset transition duration
23
+ ...options
24
+ };
25
+
26
+ // State
27
+ this.hasMoved = false;
28
+ this.currentTransform = d3.zoomIdentity;
29
+ this.zoom = null;
30
+ this.overlay = null;
31
+ this.clipPath = null;
32
+ this.clipRect = null;
33
+ this.callbacks = {
34
+ onZoom: null,
35
+ onReset: null,
36
+ onZoomStart: null,
37
+ onZoomEnd: null
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Initialize zoom behavior and setup overlay
43
+ */
44
+ initialize() {
45
+ const { root } = this.svgManager.getGroups();
46
+ const svg = this.svgManager.svg;
47
+
48
+ if (!root || !svg) {
49
+ console.warn('⚠️ Cannot initialize zoom: SVG or root group not found');
50
+ return;
51
+ }
52
+
53
+ // Create unique clip path ID
54
+ const clipId = 'trackio-clip-' + Math.random().toString(36).slice(2, 11);
55
+
56
+ // Setup clip path in SVG defs
57
+ let defs = svg.select('defs');
58
+ if (defs.empty()) {
59
+ defs = svg.append('defs');
60
+ }
61
+
62
+ this.clipPath = defs.append('clipPath')
63
+ .attr('id', clipId);
64
+
65
+ this.clipRect = this.clipPath.append('rect');
66
+
67
+ // Apply clip-path to plot groups
68
+ const { lines: gLines, points: gPoints } = this.svgManager.getGroups();
69
+ if (gLines) gLines.attr('clip-path', `url(#${clipId})`);
70
+ if (gPoints) gPoints.attr('clip-path', `url(#${clipId})`);
71
+
72
+ // Create transparent overlay for capturing zoom events
73
+ this.overlay = root.append('rect')
74
+ .attr('class', 'zoom-overlay')
75
+ .attr('fill', 'none')
76
+ .attr('pointer-events', 'all')
77
+ .style('cursor', 'grab');
78
+
79
+ // Create zoom behavior
80
+ this.zoom = d3.zoom()
81
+ .scaleExtent(this.options.zoomExtent)
82
+ .on('start', (event) => this.onZoomStart(event))
83
+ .on('zoom', (event) => this.onZoom(event))
84
+ .on('end', (event) => this.onZoomEnd(event));
85
+
86
+ // Apply zoom to overlay
87
+ this.overlay.call(this.zoom);
88
+
89
+ // Handle cursor changes
90
+ this.overlay
91
+ .on('mousedown.cursor', () => {
92
+ this.overlay.style('cursor', 'grabbing');
93
+ })
94
+ .on('mouseup.cursor', () => {
95
+ this.overlay.style('cursor', 'grab');
96
+ });
97
+
98
+ console.log('✅ ZoomManager initialized with clip-path:', clipId);
99
+ }
100
+
101
+ /**
102
+ * Update layout (call this on resize or redraw)
103
+ */
104
+ updateLayout(innerWidth, innerHeight) {
105
+ if (!this.clipRect || !this.overlay || !this.zoom) return;
106
+
107
+ // Update clip rect dimensions
108
+ this.clipRect
109
+ .attr('x', 0)
110
+ .attr('y', 0)
111
+ .attr('width', innerWidth)
112
+ .attr('height', innerHeight);
113
+
114
+ // Update overlay dimensions
115
+ this.overlay
116
+ .attr('x', 0)
117
+ .attr('y', 0)
118
+ .attr('width', innerWidth)
119
+ .attr('height', innerHeight);
120
+
121
+ // Update zoom extent and translate extent
122
+ this.zoom
123
+ .extent([[0, 0], [innerWidth, innerHeight]])
124
+ .translateExtent([[0, 0], [innerWidth, innerHeight]]);
125
+ }
126
+
127
+ /**
128
+ * Zoom start handler
129
+ */
130
+ onZoomStart(event) {
131
+ if (this.callbacks.onZoomStart) {
132
+ this.callbacks.onZoomStart(event);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Main zoom handler - rescales axes and redraws
138
+ */
139
+ onZoom(event) {
140
+ const transform = event.transform;
141
+ this.currentTransform = transform;
142
+
143
+ // Update moved state
144
+ this.hasMoved = transform.k !== 1 || transform.x !== 0 || transform.y !== 0;
145
+
146
+ // Get original scales
147
+ const { x: xScale, y: yScale } = this.svgManager.getScales();
148
+
149
+ // Rescale based on enabled axes
150
+ const newXScale = this.options.enableX ? transform.rescaleX(xScale) : xScale;
151
+ const newYScale = this.options.enableY ? transform.rescaleY(yScale) : yScale;
152
+
153
+ // Call external callback with new scales
154
+ if (this.callbacks.onZoom) {
155
+ this.callbacks.onZoom({
156
+ transform,
157
+ xScale: newXScale,
158
+ yScale: newYScale,
159
+ hasMoved: this.hasMoved
160
+ });
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Zoom end handler
166
+ */
167
+ onZoomEnd(event) {
168
+ if (this.callbacks.onZoomEnd) {
169
+ this.callbacks.onZoomEnd(event);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Reset zoom to initial state
175
+ */
176
+ reset(animated = true) {
177
+ if (!this.overlay || !this.zoom) return;
178
+
179
+ if (animated) {
180
+ this.overlay.transition()
181
+ .duration(this.options.transitionDuration)
182
+ .call(this.zoom.transform, d3.zoomIdentity);
183
+ } else {
184
+ this.overlay.call(this.zoom.transform, d3.zoomIdentity);
185
+ }
186
+
187
+ if (this.callbacks.onReset) {
188
+ this.callbacks.onReset();
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Set zoom level programmatically
194
+ */
195
+ setZoom(k, x = 0, y = 0, animated = true) {
196
+ if (!this.overlay || !this.zoom) return;
197
+
198
+ const transform = d3.zoomIdentity.translate(x, y).scale(k);
199
+
200
+ if (animated) {
201
+ this.overlay.transition()
202
+ .duration(this.options.transitionDuration)
203
+ .call(this.zoom.transform, transform);
204
+ } else {
205
+ this.overlay.call(this.zoom.transform, transform);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Enable/disable zoom
211
+ */
212
+ setEnabled(enabled) {
213
+ if (!this.overlay || !this.zoom) return;
214
+
215
+ if (enabled) {
216
+ this.overlay.call(this.zoom);
217
+ this.overlay.style('cursor', 'grab');
218
+ } else {
219
+ this.overlay.on('.zoom', null);
220
+ this.overlay.style('cursor', 'default');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Register callbacks
226
+ */
227
+ on(event, callback) {
228
+ if (this.callbacks.hasOwnProperty('on' + event.charAt(0).toUpperCase() + event.slice(1))) {
229
+ this.callbacks['on' + event.charAt(0).toUpperCase() + event.slice(1)] = callback;
230
+ } else {
231
+ console.warn(`⚠️ Unknown zoom event: ${event}`);
232
+ }
233
+ return this;
234
+ }
235
+
236
+ /**
237
+ * Get current zoom state
238
+ */
239
+ getState() {
240
+ return {
241
+ hasMoved: this.hasMoved,
242
+ transform: this.currentTransform,
243
+ scale: this.currentTransform.k,
244
+ translateX: this.currentTransform.x,
245
+ translateY: this.currentTransform.y
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Get the overlay element (for attaching additional event handlers)
251
+ */
252
+ getOverlay() {
253
+ return this.overlay;
254
+ }
255
+
256
+ /**
257
+ * Attach additional event handlers to the overlay
258
+ * This allows other managers (like InteractionManager) to use the same overlay
259
+ */
260
+ attachEventHandlers(handlers) {
261
+ if (!this.overlay) return;
262
+
263
+ Object.keys(handlers).forEach(eventName => {
264
+ this.overlay.on(eventName, handlers[eventName]);
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Cleanup
270
+ */
271
+ destroy() {
272
+ if (this.overlay) {
273
+ this.overlay.on('.zoom', null);
274
+ this.overlay.on('.cursor', null);
275
+ this.overlay.remove();
276
+ }
277
+
278
+ if (this.clipPath) {
279
+ this.clipPath.remove();
280
+ }
281
+
282
+ this.zoom = null;
283
+ this.overlay = null;
284
+ this.clipPath = null;
285
+ this.clipRect = null;
286
+ }
287
+ }
288
+
app/src/content/article.mdx CHANGED
@@ -1,57 +1,89 @@
1
  ---
2
- title: "Bringing paper to life:\n A modern template for\n scientific writing"
3
- subtitle: "Publish‑ready workflow that lets you focus on ideas, not infrastructure"
4
- description: "Publish‑ready workflow that lets you focus on ideas, not infrastructure"
5
  authors:
6
- - name: "Thibaud Frere"
7
- url: "https://huggingface.co/tfrere"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  affiliations: [1]
9
  affiliations:
10
  - name: "Hugging Face"
11
  url: "https://huggingface.co"
12
- published: "Sep. 01, 2025"
13
- doi: 10.1234/abcd.efgh
14
- licence: >
15
- Diagrams and text are licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC‑BY 4.0</a> with the source available on <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">Hugging Face</a>, unless noted otherwise.
16
- Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
17
  tags:
18
- - research
19
- - template
 
 
20
  tableOfContentsAutoCollapse: true
21
  pdfProOnly: false
22
  showPdf: true
23
  ---
24
 
 
 
 
 
 
 
 
 
 
 
 
25
  import Introduction from "./chapters/demo/introduction.mdx";
26
  import BuiltWithThis from "./chapters/demo/built-with-this.mdx";
27
  import BestPractices from "./chapters/demo/best-pratices.mdx";
28
  import WritingYourContent from "./chapters/demo/writing-your-content.mdx";
29
- import AvailableBlocks from "./chapters/demo/markdown.mdx";
30
  import GettingStarted from "./chapters/demo/getting-started.mdx";
31
  import Markdown from "./chapters/demo/markdown.mdx";
32
  import Components from "./chapters/demo/components.mdx";
33
- import Greetings from "./chapters/demo/greetings.mdx";
34
  import VibeCodingCharts from "./chapters/demo/vibe-coding-charts.mdx";
35
- import ImportContent from "./chapters/demo/import-content.mdx";
36
-
37
- <Introduction />
38
 
39
- <BuiltWithThis />
40
 
41
- <GettingStarted />
42
 
43
- <WritingYourContent />
44
 
45
- <Markdown />
46
 
47
- <Components />
48
 
49
- <VibeCodingCharts />
50
 
51
- <ImportContent />
52
 
53
- <BestPractices />
54
 
55
- <Greetings />
56
 
 
57
 
 
1
  ---
2
+ title: "Unfolding Robotics: Open-Source Shirt Folding from Data to Deployment"
3
+ subtitle: "The complete open-source recipe for teaching robots to fold clothes"
4
+ description: "We trained a bimanual robot to fold t-shirts using LeRobot and open-source hardware. We release the model, data, code, and every insight from data collection to deployment."
5
  authors:
6
+ - name: "Pepijn Kooijmans"
7
+ url: "https://huggingface.co/pepijn223"
8
+ affiliations: [1]
9
+ - name: "Michel Aractingi"
10
+ url: "https://huggingface.co/aractingi"
11
+ affiliations: [1]
12
+ - name: "Steven Palma"
13
+ url: "https://huggingface.co/imstevenpmwork"
14
+ affiliations: [1]
15
+ - name: "Caroline Pascal"
16
+ url: "https://huggingface.co/CarolinePascal"
17
+ affiliations: [1]
18
+ - name: "Jade Choghari"
19
+ url: "https://huggingface.co/jadechoghari"
20
+ affiliations: [1]
21
+ - name: "Khalil Meftah"
22
+ url: "https://huggingface.co/lilkm"
23
+ affiliations: [1]
24
+ - name: "Martino Russi"
25
+ url: "https://huggingface.co/nepyope"
26
+ affiliations: [1]
27
+ - name: "Nicolas Rabault"
28
+ url: "https://huggingface.co/Nico-robot"
29
+ affiliations: [1]
30
+ - name: "Virgile Batto"
31
+ url: "https://huggingface.co/VirgileBatto"
32
+ affiliations: [1]
33
+ - name: "Thomas Wolf"
34
+ url: "https://huggingface.co/thomwolf"
35
  affiliations: [1]
36
  affiliations:
37
  - name: "Hugging Face"
38
  url: "https://huggingface.co"
39
+ published: "2026"
 
 
 
 
40
  tags:
41
+ - robotics
42
+ - lerobot
43
+ - manipulation
44
+ - open-source
45
  tableOfContentsAutoCollapse: true
46
  pdfProOnly: false
47
  showPdf: true
48
  ---
49
 
50
+ import Hero from "./chapters/folding/01-hero.mdx";
51
+ import Results from "./chapters/folding/02-results.mdx";
52
+ import Hardware from "./chapters/folding/03-hardware.mdx";
53
+ import DataCollection from "./chapters/folding/04-data-collection.mdx";
54
+ import DataDiversity from "./chapters/folding/05-data-diversity.mdx";
55
+ import Training from "./chapters/folding/06-training.mdx";
56
+ import Evaluation from "./chapters/folding/07-evaluation.mdx";
57
+ import Experiments from "./chapters/folding/08-ablations.mdx";
58
+ import Learnings from "./chapters/folding/09-learnings.mdx";
59
+ import References from "./chapters/folding/12-references.mdx";
60
+
61
  import Introduction from "./chapters/demo/introduction.mdx";
62
  import BuiltWithThis from "./chapters/demo/built-with-this.mdx";
63
  import BestPractices from "./chapters/demo/best-pratices.mdx";
64
  import WritingYourContent from "./chapters/demo/writing-your-content.mdx";
 
65
  import GettingStarted from "./chapters/demo/getting-started.mdx";
66
  import Markdown from "./chapters/demo/markdown.mdx";
67
  import Components from "./chapters/demo/components.mdx";
 
68
  import VibeCodingCharts from "./chapters/demo/vibe-coding-charts.mdx";
 
 
 
69
 
70
+ <Hero />
71
 
72
+ <Results />
73
 
74
+ <Hardware />
75
 
76
+ <DataCollection />
77
 
78
+ <DataDiversity />
79
 
80
+ <Training />
81
 
82
+ <Evaluation />
83
 
84
+ <Experiments />
85
 
86
+ <Learnings />
87
 
88
+ <References />
89
 
app/src/content/assets/audio/audio-example.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:25334bdbaf5980acb854078acdbeb9f413f2ff71be3874e77fb5cd175403d2c9
3
+ size 146330
app/src/content/assets/image/Folding_V1.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:329351f8eb794af365639aac14a99fd988168ecd2988860ce46078451fda7d25
3
+ size 50627708
app/src/content/assets/image/footpedal.jpg ADDED

Git LFS Details

  • SHA256: dfc03134d7d7434548d30bcb5d56302a6e47d38d3a346237075be4ad25cd05ce
  • Pointer size: 130 Bytes
  • Size of remote file: 33.7 kB
app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2200_progress.gif ADDED

Git LFS Details

  • SHA256: 7d768832f930c5f86408b8fda23f8553049fb5b57091ec632396579d2338654d
  • Pointer size: 133 Bytes
  • Size of remote file: 63.7 MB
app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2500_progress.gif ADDED

Git LFS Details

  • SHA256: 1474d6da5d0bcef2321fe253d614a94682d1ebdd473c2eab8dcdae40f5a13451
  • Pointer size: 133 Bytes
  • Size of remote file: 44.7 MB
app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_grid_15x10.jpg ADDED

Git LFS Details

  • SHA256: 6fff11b2278dee0424cb0c491ac66c9256a3bf3edd533366b0b3f680ec141cc7
  • Pointer size: 132 Bytes
  • Size of remote file: 1.04 MB
app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_ep300_progress.gif ADDED

Git LFS Details

  • SHA256: a01ccc2c5b789390b0df70a18a37abb65c8ddf3903e98791138ec65b30801b90
  • Pointer size: 134 Bytes
  • Size of remote file: 161 MB
app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_grid_15x10.jpg ADDED

Git LFS Details

  • SHA256: 4ac005e50b1accd0c024f53c4239463abe9cb93dc48c31ff6cd1b490aedaae3f
  • Pointer size: 131 Bytes
  • Size of remote file: 852 kB
app/src/content/assets/image/maintain-the-unmaintainable.png CHANGED

Git LFS Details

  • SHA256: 52db98b81d3399e673679d415498cd585915955223f296b8bc49b28095542b0f
  • Pointer size: 131 Bytes
  • Size of remote file: 218 kB

Git LFS Details

  • SHA256: 6b265d8ee4ca1413cd3af59cbf307531e0d11af8402070d54757f15ea2032cdf
  • Pointer size: 132 Bytes
  • Size of remote file: 1.17 MB
app/src/content/assets/image/ogp.webp ADDED

Git LFS Details

  • SHA256: 617e637915c6e45c7c536511543142a3bee2693de51096f18e9890621d215e52
  • Pointer size: 131 Bytes
  • Size of remote file: 220 kB
app/src/content/assets/image/openarm-mini1.jpg ADDED

Git LFS Details

  • SHA256: 39a56b3c1112ebc7cd13c2ad7792047c1bfc8ad668f64c76447a39705cc84b3d
  • Pointer size: 132 Bytes
  • Size of remote file: 1.94 MB
app/src/content/assets/image/openarm-mini2.jpg ADDED

Git LFS Details

  • SHA256: ccfe8c0e6cccc222e4f4e2c4eb71b1ea6c4badd4a742eb73e65946856099cb84
  • Pointer size: 132 Bytes
  • Size of remote file: 3.01 MB
app/src/content/assets/image/robot_folding.png ADDED

Git LFS Details

  • SHA256: cc3aa8fc11d81071989dc59956d25c5b61f3f47fa284d47266930f2edd5de25e
  • Pointer size: 132 Bytes
  • Size of remote file: 2.64 MB
app/src/content/assets/image/smoll-training-guide.png CHANGED

Git LFS Details

  • SHA256: 9338752555b50cbf5f96f5a4579250d7d09ea6a0781fc7aa0b402eb41f542105
  • Pointer size: 130 Bytes
  • Size of remote file: 78.9 kB

Git LFS Details

  • SHA256: 8a41f479bd3b922ddd723ccf80b7bebf3e8f875ee1049794d472c67e23f0cc12
  • Pointer size: 131 Bytes
  • Size of remote file: 163 kB
app/src/content/bibliography.bib CHANGED
@@ -1,130 +1,110 @@
1
- @inproceedings{vaswani2017attention,
2
- title = {Attention Is All You Need},
3
- author = {Vaswani, Ashish and Shazeer, Noam and Parmar, Niki and Uszkoreit, Jakob and Jones, Llion and Gomez, Aidan N and Kaiser, {
4
- }Lukasz and Polosukhin, Illia},
5
- booktitle = {Advances in Neural Information Processing Systems},
6
- year = {2017}
7
  }
8
 
9
- @book{mckinney2017python,
10
- title = {Python for Data Analysis},
11
- author = {McKinney, Wes},
12
- publisher = {O'Reilly Media},
13
- address = {Sebastopol, CA},
14
- year = {2017},
15
- edition = {2},
16
- isbn = {978-1491957660}
17
  }
18
 
19
- @inproceedings{he2016resnet,
20
- title = {Deep Residual Learning for Image Recognition},
21
- author = {He, Kaiming and Zhang, Xiangyu and Ren, Shaoqing and Sun, Jian},
22
- booktitle = {Proceedings of the IEEE Conference on Computer Vision and Pattern Recognition (CVPR)},
23
- pages = {770--778},
24
- year = {2016},
25
- doi = {10.1109/CVPR.2016.90},
26
- url = {https://doi.org/10.1109/CVPR.2016.90}
27
  }
28
 
29
- @article{silver2017mastering,
30
- title = {Mastering the game of Go without human knowledge},
31
- author = {Silver, David and Schrittwieser, Julian and Simonyan, Karen and Antonoglou, Ioannis and Huang, Aja and others},
32
- journal = {Nature},
33
- volume = {550},
34
- number = {7676},
35
- pages = {354--359},
36
- year = {2017},
37
- month = {oct},
38
- doi = {10.1038/nature24270},
39
- url = {https://www.nature.com/articles/nature24270}
40
  }
41
 
42
- @techreport{openai2023gpt4,
43
- title = {GPT-4 Technical Report},
44
- author = {{OpenAI}},
45
- institution = {OpenAI},
46
- year = {2023},
47
- number = {arXiv:2303.08774},
48
- archiveprefix = {arXiv},
49
- eprint = {2303.08774},
50
- primaryclass = {cs.CL},
51
- url = {https://arxiv.org/abs/2303.08774}
52
  }
53
 
54
- @phdthesis{doe2020thesis,
55
- title = {Learning Efficient Representations for Large-Scale Visual Recognition},
56
- author = {Doe, Jane},
57
- school = {Massachusetts Institute of Technology},
58
- address = {Cambridge, MA},
59
- year = {2020},
60
- doi = {10.5555/mit-2020-xyz}
61
  }
62
 
63
- @incollection{cover2006entropy,
64
- title = {Entropy, Relative Entropy, and Mutual Information},
65
- author = {Cover, Thomas M. and Thomas, Joy A.},
66
- booktitle = {Elements of Information Theory},
67
- publisher = {Wiley},
68
- address = {Hoboken, NJ},
69
- edition = {2},
70
- year = {2006},
71
- pages = {13--55},
72
- isbn = {978-0471241959}
73
  }
74
 
75
- @misc{zenodo2021dataset,
76
- title = {ImageNet-21K Subset (Version 2.0)},
77
- author = {Smith, John and Lee, Alice and Kumar, Ravi},
78
- year = {2021},
79
- howpublished = {Dataset on Zenodo},
80
- doi = {10.5281/zenodo.1234567},
81
- url = {https://doi.org/10.5281/zenodo.1234567},
82
- note = {Accessed 2025-09-01}
83
  }
84
 
85
- @misc{sklearn2024,
86
- title = {scikit-learn: Machine Learning in Python (Version 1.4)},
87
- author = {Pedregosa, Fabian and Varoquaux, Ga{"e}l and Gramfort, Alexandre and others},
88
- year = {2024},
89
- howpublished = {Software},
90
- doi = {10.5281/zenodo.592264},
91
- url = {https://scikit-learn.org}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  }
93
 
94
- @inproceedings{smith2024privacy,
95
- title = {Privacy-Preserving Training with Low-Precision Secure Aggregation},
96
- author = {Smith, Emily and Zhang, Wei and Rossi, Marco and Patel, Neha},
97
- booktitle = {Proceedings of the 41st International Conference on Machine Learning},
98
- editor = {Smith, A. and Johnson, B.},
99
- series = {Proceedings of Machine Learning Research},
100
- volume = {235},
101
- pages = {12345--12367},
102
- address = {Vienna, Austria},
103
- publisher = {PMLR},
104
- month = {jul},
105
- year = {2024},
106
- url = {https://proceedings.mlr.press/v235/}
107
  }
108
 
109
- @article{kingma2015adam,
110
- title = {Adam: A Method for Stochastic Optimization},
111
- author = {Kingma, Diederik P. and Ba, Jimmy},
112
- journal = {International Conference on Learning Representations (ICLR)},
113
- year = {2015},
114
- archiveprefix = {arXiv},
115
- eprint = {1412.6980},
116
- primaryclass = {cs.LG},
117
- url = {https://arxiv.org/abs/1412.6980}
118
  }
119
 
120
- @misc{raffel2020t5,
121
- title = {Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer},
122
- author = {Raffel, Colin and Shazeer, Noam and Roberts, Adam and Lee, Katherine and Narang, Sharan and others},
123
- year = {2020},
124
- howpublished = {arXiv preprint},
125
- archiveprefix = {arXiv},
126
- eprint = {1910.10683},
127
- primaryclass = {cs.LG},
128
- doi = {10.48550/arXiv.1910.10683},
129
- url = {https://arxiv.org/abs/1910.10683}
130
  }
 
1
+ @article{black2024pi0,
2
+ title = {$\pi_0$: A Vision-Language-Action Flow Model for General Robot Control},
3
+ author = {Black, Kevin and Brown, Noah and Driess, Danny and Esmail, Adnan and Equi, Michael and Finn, Chelsea and Fusai, Niccolo and Groom, Lachy and Hausman, Karol and Ichter, Brian and others},
4
+ journal = {arXiv preprint arXiv:2410.24164},
5
+ year = {2024}
 
6
  }
7
 
8
+ @inproceedings{black2025pi05,
9
+ title = {$\pi_{0.5}$: A Vision-Language-Action Model with Open-World Generalization},
10
+ author = {Black, Kevin and Brown, Noah and Darpinian, James and Dhabalia, Karan and Driess, Danny and Esmail, Adnan and Equi, Michael and Finn, Chelsea and others},
11
+ booktitle = {9th Annual Conference on Robot Learning},
12
+ year = {2025},
13
+ url = {https://arxiv.org/abs/2504.16054}
 
 
14
  }
15
 
16
+ @article{pi2025pistar06,
17
+ title = {$\pi^*_{0.6}$: A VLA That Learns From Experience},
18
+ author = {{Physical Intelligence}},
19
+ journal = {arXiv preprint},
20
+ year = {2025},
21
+ url = {https://pi.website/blog/pistar06}
 
 
22
  }
23
 
24
+ @misc{cadene2024lerobot,
25
+ title = {LeRobot: State-of-the-art Machine Learning for Real-World Robotics in PyTorch},
26
+ author = {Cadene, Remi and Alibert, Simon and Soare, Alexander and others},
27
+ year = {2024},
28
+ howpublished = {GitHub},
29
+ url = {https://github.com/huggingface/lerobot}
 
 
 
 
 
30
  }
31
 
32
+ @inproceedings{black2025rtc,
33
+ title = {Real-Time Execution of Action Chunking Flow Policies},
34
+ author = {Black, Kevin and Galliker, Manuel Y. and Levine, Sergey},
35
+ booktitle = {NeurIPS},
36
+ year = {2025},
37
+ url = {https://arxiv.org/abs/2506.07339}
 
 
 
 
38
  }
39
 
40
+ @article{chen2025sarm,
41
+ title = {SARM: Stage-Aware Reward Modeling for Long Horizon Robot Manipulation},
42
+ author = {Chen, Qianzhong and Yu, Justin and Schwager, Mac and Abbeel, Pieter and Shentu, Yide and Wu, Philipp},
43
+ journal = {arXiv preprint arXiv:2509.25358},
44
+ year = {2025},
45
+ url = {https://arxiv.org/abs/2509.25358}
 
46
  }
47
 
48
+ @inproceedings{ross2011dagger,
49
+ title = {A Reduction of Imitation Learning and Structured Prediction to No-Regret Online Learning},
50
+ author = {Ross, St{\'e}phane and Gordon, Geoffrey and Bagnell, Drew},
51
+ booktitle = {AISTATS},
52
+ pages = {627--635},
53
+ year = {2011}
 
 
 
 
54
  }
55
 
56
+ @article{lipman2022flow,
57
+ title = {Flow Matching for Generative Modeling},
58
+ author = {Lipman, Yaron and Chen, Ricky TQ and Ben-Hamu, Heli and Nickel, Maximilian and Le, Matt},
59
+ journal = {arXiv preprint arXiv:2210.02747},
60
+ year = {2022}
 
 
 
61
  }
62
 
63
+ @inproceedings{vaswani2017attention,
64
+ title = {Attention Is All You Need},
65
+ author = {Vaswani, Ashish and Shazeer, Noam and Parmer, Niki and Uszkoreit, Jakob and Jones, Llion and Gomez, Aidan N and Kaiser, Lukasz and Polosukhin, Illia},
66
+ booktitle = {Advances in Neural Information Processing Systems},
67
+ year = {2017}
68
+ }
69
+
70
+ @article{chi2023diffusionpolicy,
71
+ title = {Diffusion Policy: Visuomotor Policy Learning via Action Diffusion},
72
+ author = {Chi, Cheng and Feng, Siyuan and Du, Yilun and Xu, Zhenjia and Cousineau, Eric and Burchfiel, Benjamin and Song, Shuran},
73
+ journal = {Robotics: Science and Systems},
74
+ year = {2023}
75
+ }
76
+
77
+ @article{driess2025ki,
78
+ title = {Knowledge Insulating Vision-Language-Action Models: Train Fast, Run Fast, Generalize Better},
79
+ author = {Driess, Danny and Springenberg, Jost Tobias and Ichter, Brian and Yu, Lili and Li-Bell, Adrian and Pertsch, Karl and others},
80
+ journal = {NeurIPS},
81
+ year = {2025}
82
+ }
83
+
84
+ @article{pertsch2025fast,
85
+ title = {FAST: Efficient Action Tokenization for Vision-Language-Action Models},
86
+ author = {Pertsch, Karl and Stachowicz, Kyle and Ichter, Brian and Driess, Danny and Nair, Suraj and Vuong, Quan and Mees, Oier and Finn, Chelsea and Levine, Sergey},
87
+ journal = {Robotics: Science and Systems},
88
+ year = {2025}
89
  }
90
 
91
+ @article{frans2025cfgrl,
92
+ title = {Diffusion Guidance is a Controllable Policy Improvement Operator},
93
+ author = {Frans, Kevin and Park, Seohong and Abbeel, Pieter and Levine, Sergey},
94
+ journal = {arXiv preprint arXiv:2505.23458},
95
+ year = {2025}
 
 
 
 
 
 
 
 
96
  }
97
 
98
+ @article{shukor2025smolvla,
99
+ title = {SmolVLA: A Small Vision-Language-Action Model for Robotics},
100
+ author = {Shukor, Mustafa and others},
101
+ journal = {arXiv preprint},
102
+ year = {2025}
 
 
 
 
103
  }
104
 
105
+ @article{kelly2019hgdagger,
106
+ title = {HG-DAgger: Interactive Imitation Learning with Human Experts},
107
+ author = {Kelly, Michael and Sidrane, Chelsea and Driggs-Campbell, Katherine and Kochenderfer, Mykel J},
108
+ journal = {ICRA},
109
+ year = {2019}
 
 
 
 
 
110
  }
app/src/content/chapters/demo/built-with-this.mdx CHANGED
@@ -11,14 +11,18 @@ export const title = "Built with this";
11
  You can see how the template is used in the following examples.
12
 
13
  <Stack direction="horizontal" gap="medium" layout="2-column" >
14
- <a href="https://huggingface.co/spaces/transformers-community/Transformers-tenets" target="_blank" rel="noopener noreferrer" class="card no-padding" style="flex: 1; min-width: 0; overflow: hidden;">
 
 
15
  <Image
16
- src={maintainUnmaintainable}
17
- alt="Maintain the unmaintainable: 1M python loc, 400+ models"
 
 
18
  />
19
  <div class="card-title-container">
20
- <h3 class="card-title">Maintain the unmaintainable: 1M python loc, 400+ models</h3>
21
- <p class="card-subtitle">A peek into software engineering for the transformers library</p>
22
  </div>
23
  </a>
24
 
@@ -26,6 +30,8 @@ You can see how the template is used in the following examples.
26
  <Image
27
  src={finevision}
28
  alt="FineVision: Open Data Is All You Need"
 
 
29
  />
30
  <div class="card-title-container">
31
  <h3 class="card-title">FineVision: Open Data Is All You Need</h3>
@@ -33,14 +39,17 @@ You can see how the template is used in the following examples.
33
  </div>
34
  </a>
35
 
36
- <a href="https://placeholder-url.com" target="_blank" rel="noopener noreferrer" class="card no-padding" style="flex: 1; min-width: 0; overflow: hidden;">
 
37
  <Image
38
- src={smolTrainingGuide}
39
- alt="The Smol Training Guide: The Secrets to Building World-Class LLMs"
 
 
40
  />
41
  <div class="card-title-container">
42
- <h3 class="card-title">The Smol Training Guide: The Secrets to Building World-Class LLMs</h3>
43
- <p class="card-subtitle">A practical journey through the challenges, decisions, and messy reality behind training state-of-the-art language models</p>
44
  </div>
45
  </a>
46
 
 
11
  You can see how the template is used in the following examples.
12
 
13
  <Stack direction="horizontal" gap="medium" layout="2-column" >
14
+
15
+
16
+ <a href="https://huggingface.co/spaces/HuggingFaceTB/smol-training-playbook" target="_blank" rel="noopener noreferrer" class="card no-padding" style="flex: 1; min-width: 0; overflow: hidden;">
17
  <Image
18
+ src={smolTrainingGuide}
19
+ alt="The Smol Training Playbook: The Secrets to Building World-Class LLMs"
20
+ zoomable={false}
21
+ downloadable={false}
22
  />
23
  <div class="card-title-container">
24
+ <h3 class="card-title">The Smol Training Playbook: The Secrets to Building World-Class LLMs</h3>
25
+ <p class="card-subtitle">A practical journey through the challenges, decisions, and messy reality behind training state-of-the-art language models</p>
26
  </div>
27
  </a>
28
 
 
30
  <Image
31
  src={finevision}
32
  alt="FineVision: Open Data Is All You Need"
33
+ zoomable={false}
34
+ downloadable={false}
35
  />
36
  <div class="card-title-container">
37
  <h3 class="card-title">FineVision: Open Data Is All You Need</h3>
 
39
  </div>
40
  </a>
41
 
42
+
43
+ <a href="https://huggingface.co/spaces/transformers-community/Transformers-tenets" target="_blank" rel="noopener noreferrer" class="card no-padding" style="flex: 1; min-width: 0; overflow: hidden;">
44
  <Image
45
+ src={maintainUnmaintainable}
46
+ alt="Maintain the unmaintainable: 1M python loc, 400+ models"
47
+ zoomable={false}
48
+ downloadable={false}
49
  />
50
  <div class="card-title-container">
51
+ <h3 class="card-title">Maintain the unmaintainable: 1M python loc, 400+ models</h3>
52
+ <p class="card-subtitle">A peek into software engineering for the transformers library</p>
53
  </div>
54
  </a>
55
 
app/src/content/chapters/demo/components.mdx CHANGED
@@ -1,6 +1,6 @@
1
  import { Image as AstroImage } from 'astro:assets';
2
  import placeholder from '../../assets/image/placeholder.png';
3
- import audioDemo from '../../assets/audio/audio-example.wav';
4
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
5
  import Sidenote from '../../../components/Sidenote.astro';
6
  import Wide from '../../../components/Wide.astro';
 
1
  import { Image as AstroImage } from 'astro:assets';
2
  import placeholder from '../../assets/image/placeholder.png';
3
+ import audioDemo from '../../assets/audio/audio-example.mp3';
4
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
5
  import Sidenote from '../../../components/Sidenote.astro';
6
  import Wide from '../../../components/Wide.astro';
app/src/content/chapters/demo/import-content.mdx CHANGED
@@ -41,34 +41,59 @@ Set `ENABLE_LATEX_CONVERSION=true` in your Hugging Face Space to enable automati
41
 
42
  Convert Notion pages into interactive web articles.
43
 
44
- ### Quick Start
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  ```bash
47
  cd app/scripts/notion-importer/
48
  npm install
49
  cp env.example .env
50
- # Edit .env with your Notion token
51
- # Edit input/pages.json with your page IDs
52
- node index.mjs
53
  ```
54
 
 
 
55
  ### What Gets Converted
56
 
57
- - Images
58
- - Callouts → `<Sidenote>` components
59
  - Enhanced tables and code blocks
60
- - Smart link conversion
61
-
62
- ### Prerequisites
63
-
64
- - **Node.js** with ESM support
65
- - **Notion Integration** with token
66
- - **Shared Pages** with your integration
67
-
68
- <Note variant="info">
69
- 💡 **Hugging Face Spaces** — Add your `NOTION_TOKEN` to Space secrets for secure access.
70
- </Note>
71
-
72
- ### Docker Deployment
73
-
74
- Set `ENABLE_NOTION_CONVERSION=true` in your Hugging Face Space to enable automatic conversion during build.
 
41
 
42
  Convert Notion pages into interactive web articles.
43
 
44
+ ### Prerequisites
45
+
46
+ You need **2 things**:
47
+
48
+ 1. **NOTION_TOKEN** — Your Notion integration token
49
+ - Go to [notion.so/my-integrations](https://www.notion.so/my-integrations)
50
+ - Create a new integration → copy the token (starts with `secret_` or `ntn_`)
51
+
52
+ 2. **NOTION_PAGE_ID** — The ID of your Notion page
53
+ - Open your page in browser: `https://www.notion.so/My-Page-abc123def456`
54
+ - The ID is the last part after the title: `abc123def456`
55
+
56
+ ### Share Your Page with the Integration
57
+
58
+ <Note variant="warning">
59
+ **This step is required!** Having a token is not enough — you must explicitly share the page with your integration.
60
+ </Note>
61
+
62
+ 1. Open your Notion page
63
+ 2. Click **"..."** (top right corner)
64
+ 3. Click **"Connections"** or **"Add connections"**
65
+ 4. Select your integration from the list
66
+ 5. Confirm access
67
+
68
+ If your page is nested inside another page or database, you can either share the parent page (the integration will have access to all sub-pages) or share each page individually.
69
+
70
+ ### Option A: Automatic on HF Space
71
+
72
+ Add these to your Space settings (Settings → Variables and secrets):
73
+
74
+ | Type | Name | Value |
75
+ |------|------|-------|
76
+ | Secret | `NOTION_TOKEN` | your token |
77
+ | Variable | `NOTION_PAGE_ID` | your page ID |
78
+ | Variable | `ENABLE_NOTION_CONVERSION` | `true` |
79
+
80
+ Then restart the Space. Every rebuild will fetch the latest Notion content automatically.
81
+
82
+ ### Option B: Local Development
83
 
84
  ```bash
85
  cd app/scripts/notion-importer/
86
  npm install
87
  cp env.example .env
88
+ # Edit .env with NOTION_TOKEN and NOTION_PAGE_ID
89
+ npm run notion:import # or: node index.mjs
 
90
  ```
91
 
92
+ This rewrites `src/content/article.mdx` with your Notion content. Push to deploy.
93
+
94
  ### What Gets Converted
95
 
96
+ - Images (downloaded locally)
97
+ - Callouts → `<Note>` components
98
  - Enhanced tables and code blocks
99
+ - Smart link conversion
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/content/chapters/demo/markdown.mdx CHANGED
@@ -1,5 +1,5 @@
1
  import placeholder from '../../assets/image/placeholder.png';
2
- import audioDemo from '../../assets/audio/audio-example.wav';
3
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
4
  import Sidenote from '../../../components/Sidenote.astro';
5
  import Wide from '../../../components/Wide.astro';
@@ -453,7 +453,7 @@ Embed audio using `<audio controls src={...} />`.
453
  <br/>
454
  <Accordion title="Code example">
455
  ```mdx
456
- import audioDemo from './assets/audio/audio-example.wav'
457
 
458
  <audio controls src={audioDemo}/>
459
  ```
 
1
  import placeholder from '../../assets/image/placeholder.png';
2
+ import audioDemo from '../../assets/audio/audio-example.mp3';
3
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
4
  import Sidenote from '../../../components/Sidenote.astro';
5
  import Wide from '../../../components/Wide.astro';
 
453
  <br/>
454
  <Accordion title="Code example">
455
  ```mdx
456
+ import audioDemo from './assets/audio/audio-example.mp3'
457
 
458
  <audio controls src={audioDemo}/>
459
  ```
app/src/content/chapters/demo/writing-your-content.mdx CHANGED
@@ -8,7 +8,7 @@ import FullWidth from '../../../components/FullWidth.astro';
8
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
9
  import ColorPicker from '../../../components/demo/ColorPicker.astro';
10
  import Palettes from '../../../components/demo/Palettes.astro';
11
- import audioDemo from '../../assets/audio/audio-example.wav';
12
  import Accordion from '../../../components/Accordion.astro';
13
 
14
  ## Writing your content
 
8
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
9
  import ColorPicker from '../../../components/demo/ColorPicker.astro';
10
  import Palettes from '../../../components/demo/Palettes.astro';
11
+ import audioDemo from '../../assets/audio/audio-example.mp3';
12
  import Accordion from '../../../components/Accordion.astro';
13
 
14
  ## Writing your content
app/src/content/chapters/folding/01-hero.mdx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Sidenote from "../../../components/Sidenote.astro";
2
+ import Note from "../../../components/Note.astro";
3
+ import Wide from "../../../components/Wide.astro";
4
+ import Stack from "../../../components/Stack.astro";
5
+
6
+ We went from 0% to 90% success rate on autonomous t-shirt folding, and the biggest lever wasn't the model. It was the data.
7
+
8
+ <Sidenote>
9
+ Read time: ~30 minutes. Each section stands on its own feel free to skip to what interests you most.
10
+ </Sidenote>
11
+
12
+ This isn't a model release. It's the full behind-the-scenes of training an open-source bimanual robot to fold t-shirts. Published demos share insights and show polished results, but the reality is messier, and more iterative.
13
+
14
+ In this blog we will walk you through the complete journey, not just the final recipe that worked, but also the surprising lessons and the small details that turned out to matter more than we expected. You'll see why cheap 3D-printed leader arms helped more than the large ones, why early data collection is more wasteful than you'd think, and how a trained reward model helped us separate good demonstrations from bad ones.
15
+
16
+ By sharing this we hope to contribute to our bigger vision: **democratize robotics and robot learning**. By open-sourcing every piece tools, data, models, and knowledge we want to enable a community that pushes this technology further. We've tried to avoid just listing what we did in favor of telling the story of how we got here. We hope being this open will help close the gap between closed-lab demos and what the open-source community can achieve.
17
+
18
+ Everything we built for this project [SARM](https://huggingface.co/docs/lerobot/sarm), [RTC](https://huggingface.co/docs/lerobot/rtc), DAgger, [Open Arms](https://huggingface.co/docs/lerobot/openarm), and Open Arms Mini is now merged into [LeRobot](https://github.com/huggingface/lerobot) and ready for the community to use.
19
+
20
+ Let's jump in does it actually work?
21
+
22
+ #### Links
23
+
24
+ <Stack layout="4-column" gap="small">
25
+ <a href="https://huggingface.co/lerobot-data-collection/folding_final" className="card" style="padding: 12px 16px; text-align: center; text-decoration: none;">**Model** HF Hub</a>
26
+ <a href="https://huggingface.co/lerobot-data-collection/folding_sarm_reward" className="card" style="padding: 12px 16px; text-align: center; text-decoration: none;">**SARM Reward** HF Hub</a>
27
+ <a href="https://huggingface.co/datasets/lerobot/high_quality_folding" className="card" style="padding: 12px 16px; text-align: center; text-decoration: none;">**HQ Dataset** HF Hub</a>
28
+ <a href="https://huggingface.co/datasets/lerobot/full_folding" className="card" style="padding: 12px 16px; text-align: center; text-decoration: none;">**Full Dataset** HF Hub</a>
29
+ <a href="https://github.com/huggingface/lerobot" className="card" style="padding: 12px 16px; text-align: center; text-decoration: none;">**Code** LeRobot</a>
30
+ <a href="https://huggingface.co/docs/lerobot/openarm" className="card" style="padding: 12px 16px; text-align: center; text-decoration: none;">**Open Arms Mini** Repo</a>
31
+ </Stack>
32
+
33
+ <Sidenote>
34
+ If you have questions, join our <a href="https://discord.com/invite/q8Dzzpym3f" target="_blank">Discord</a>!
35
+ </Sidenote>
app/src/content/chapters/folding/02-results.mdx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+ import Stack from "../../../components/Stack.astro";
4
+ import Accordion from "../../../components/Accordion.astro";
5
+ import Wide from "../../../components/Wide.astro";
6
+ import Video from "../../../components/Video.astro";
7
+
8
+ ## Results
9
+
10
+ Below are two **uncut, full-length** runs from our best model. No human intervention.
11
+
12
+ **Level 1: Fold a laid-out t-shirt** (15 min continuous folding)
13
+
14
+ <Wide>
15
+ <Video src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/level1.mp4" />
16
+ </Wide>
17
+
18
+ **Level 2: Untangle, spread, fold, and place aside** (5 shirts back-to-back)
19
+
20
+ <Wide>
21
+ <Video src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/level2.mp4" />
22
+ </Wide>
23
+
24
+ ### Key Metrics
25
+
26
+ How well does it actually work? We evaluated our best model (Experiment 2.5) across 20 rollouts.
27
+
28
+ | Task | Success Rate | Avg. Completion Time |
29
+ |:---|:---:|:---:|
30
+ | **Level 1** Laid-out to Fold | **100%** | **40.8 s** |
31
+ | **Level 2** Messy to Spread to Fold to Place aside | **80%** | **95.9 s** |
32
+ | **Combined** (Total SR) | **90%** | |
33
+
34
+ <Sidenote>
35
+ All evaluations filmed and scored from video. 20 rollouts per experiment (10 per level). Full methodology in the Evaluation section.
36
+ </Sidenote>
37
+
38
+ These numbers are the result of 11 experiments, each testing a different combination of model, data, and training strategies. The full breakdown is in the [Experiments](#experiments) section. Let's start from the beginning: the hardware.
app/src/content/chapters/folding/03-hardware.mdx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+ import Stack from "../../../components/Stack.astro";
4
+ import Accordion from "../../../components/Accordion.astro";
5
+ import Image from "../../../components/Image.astro";
6
+ import openArmsImg from "../../assets/image/ogp.webp";
7
+ import footpedalImg from "../../assets/image/footpedal.jpg";
8
+ import openArmMini1 from "../../assets/image/openarm-mini1.jpg";
9
+ import openArmMini2 from "../../assets/image/openarm-mini2.jpg";
10
+
11
+ ## Hardware
12
+
13
+ First things first: you need a robot. And not just a robot you need a way to control it, a way to see what it's doing, and a way to record demonstrations. Luckily, that's where LeRobot comes in. In this section we walk through every piece of hardware we used. Some of these choices were obvious in advance. Others like the wrist strap and small details turned out to be surprisingly important.
14
+
15
+ ### The Robot: Bimanual Open Arms
16
+
17
+ We use the **bimanual [Open Arms](https://huggingface.co/docs/lerobot/openarm)**, they are open-source, human-like robot arms developed by [Enetic](https://openarm.dev) and built by [WowRobot](https://shop.wowrobo.com). Three reasons drove this choice:
18
+
19
+ 1. **The humanoid trend.** We're seeing a wave of human-like robots. More human-form robots means more human-form data in the ecosystem. Building on this form factor positions our work for a future where human-like manipulation data is transferable.
20
+ 2. **Smaller teleop gap.** When the robot's kinematics match a human arm, the teleoperator's motions transfer more naturally less mental remapping, faster learning.
21
+ 3. **Open source, good specs.** Solid payload, good reach, and fully open hardware. We extended the upper arm by **+5 cm** to increase reach since our setup doesn't have a hip or torso to provide additional workspace.
22
+
23
+ Everything is mounted on **aluminum extrusion profiles**, which let us quickly iterate on the physical arrangement and adjust both teleop and robot height between sessions to increase data diversity.
24
+
25
+ <img src={openArmsImg.src} alt="Open Arms bimanual robot setup" style="width:100%; border-radius: 8px;" />
26
+
27
+ ### Teleop Arms: Open Arms Mini
28
+
29
+ Next challenge: how do you actually control the robot?
30
+
31
+ We started with full-size Open Arms as leader arms for teleoperation. They seemed like the natural choice: same kinematics as the follower arms, one-to-one mapping.
32
+
33
+ However, we quickly realized we needed something with less inertia so operators could move faster and with more precision and something that works regardless of arm length, since our operators varied significantly in height. This led us to develop the **Open Arms Mini**: small, Feetech-based, 3D-printed leader arms based on the [SO-101](https://github.com/TheRobotStudio/SO-ARM100) design. These gave us:
34
+ - **Less inertia** operators could make quicker and more deliberate motions that cloth folding demands
35
+ - **Arm-length agnostic** works for teleoperators of any size
36
+ - **Incredibly cheap** ~120 EUR per arm, making it very cheap to set up multiple stations
37
+ - **Still support DAgger** lightweight, but strong enough to move during human-in-the-loop correction data collection
38
+
39
+ One detail turned out to be critical: the **wrist strap**. Without it, wrist rotations were imprecise. With the strap, operators get locked-in wrist control, which is essential for cloth manipulation.
40
+
41
+ <Note variant="info" emoji="🔗">
42
+ Open Arms Mini repo (3D print files, BOM, LeRobot integration): <a href="#" target="_blank">github.com/.../opens-mini</a>
43
+ </Note>
44
+
45
+ <div style="display: flex; gap: 8px; max-width: 70%; margin: 0 auto;">
46
+ <img src={openArmMini1.src} alt="Open Arms Mini leader arm" style="width: 50%; border-radius: 8px; object-fit: cover;" />
47
+ <div style="width: 50%; display: flex; flex-direction: column; gap: 8px;">
48
+ <img src={openArmMini2.src} alt="Open Arms Mini leader arm with wrist strap" style="width: 100%; border-radius: 8px; flex: 1; object-fit: cover;" />
49
+ <img src={footpedalImg.src} alt="USB foot pedal for episode control" style="width: 100%; border-radius: 8px; flex: 1; object-fit: cover;" />
50
+ </div>
51
+ </div>
52
+
53
+ A small thing that makes a surprisingly big difference: when both your hands are on the leader arms, you need a hands-free way to **start and stop episodes**. USB foot pedals solved this elegantly.
54
+
55
+ ### Cameras
56
+
57
+ We use **three cameras** each serving a distinct purpose:
58
+
59
+ | Camera | Position | Notes |
60
+ |:---|:---|:---|
61
+ | **Base camera** | Mounted between/above arms | Wide FOV to capture the full scene |
62
+ | **Left wrist camera** | Mounted on left end-effector | Close-up view for precise manipulation |
63
+ | **Right wrist camera** | Mounted on right end-effector | Close-up view for precise manipulation |
64
+
65
+ <Note variant="info" emoji="🔗">
66
+ Camera links: <a href="https://www.amazon.fr/-/en/Fafeicy-Camera-Module-Million-Conferencing/dp/B08GLSPTXY" target="_blank">Base camera (Fafeicy OV2710)</a> / <a href="https://www.arducam.com/12mp-imx708-usb-uvc-102-wide-angle-fixed-focus-camera-module-3.html" target="_blank">Wrist cameras (Arducam IMX708)</a>
67
+ </Note>
68
+
69
+ <Sidenote>
70
+ The base camera has a slight fisheye effect which is totally fine, as the model learns to handle it.
71
+ </Sidenote>
72
+
73
+
74
+ ### LeRobot Integration
75
+
76
+ Integrating Open Arms into LeRobot required adding **CAN-bus protocol** support for the arm's motors, which can be found in the [LeRobot repository](https://github.com/huggingface/lerobot). We also created a UI for the non-technical robot operators, so they don't have to use the CLI to start and stop episodes.
77
+
78
+
79
+ With the hardware in place, the next step was the hardest and most time-consuming part of the entire project: collecting good data. And "good" is much harder to define than it sounds.
app/src/content/chapters/folding/04-data-collection.mdx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+ import Accordion from "../../../components/Accordion.astro";
4
+
5
+ ## Data Collection
6
+
7
+ Data collection was the longest phase of this project, and arguably the most important. No amount of compute can compensate for bad demonstrations.
8
+
9
+ We ran **8 setups** in parallel, optimizing for **maximum diversity**: 25+ different t-shirts, 8 different backgrounds, and varying camera and robot heights between sessions. We structured collection into two task levels: **Level 1** (fold a laid-out shirt) and **Level 2** (spread a messy shirt, fold it, place it aside).
10
+
11
+ ### Learning to Teleoperate
12
+
13
+ Here's an honest truth: **early data is worse than the final data**. Teleoperating a bimanual robot is a genuine skill, and it takes practice. The first episodes are slow, not deliberate, and full of failed attempts. Over hours of practice, operators get dramatically better smoother motions, faster execution, and more consistent grasps.
14
+
15
+ This creates one of the most important practical decisions of the project: **when do you start recording data for the final model?** Too early and you pollute the dataset with low-quality demonstrations that the model will faithfully reproduce, hesitations, fumbles, and all. Too late and you've wasted precious time.
16
+
17
+ Another important part is aligning the strategy between operators. Since some parts of folding are very multi-modal (you can fold a t-shirt in many different ways), you need to make sure there is a common strategy. We held brief alignment sessions to standardize the fold sequence before each recording sprint, where we first experimented with different approaches, then shared our learnings and discussed to find the best or most efficient way.
18
+
19
+ ### Tips for Good Data Collection
20
+
21
+ 1. **Practice before you record.** Smooth, deliberate motions beat fast, sloppy ones.
22
+ 2. **Quality over speed. Always.** A fast but messy episode teaches bad habits that are hard to untrain.
23
+ 3. **Each action should make sense from the current observation alone.** Most models don't have history, so avoid motions that only work because *you* remember what happened 5 seconds ago.
24
+ 4. **Be consistent within episodes.** The model learns a coherent strategy more easily than movements that vary wildly each time.
25
+ 5. **Start small, then extend.** Train a quick model, see what fails, then add diversity. Don't try to collect the perfect dataset on day one.
26
+ 6. **Speed comes last.** Once you've dialed in quality and a consistent strategy, optimize for speed. But never sacrifice quality for it.
27
+
28
+ After learning all these things and collecting data for multiple weeks we ended up with 5,688 episodes across 8 setups.
app/src/content/chapters/folding/05-data-diversity.mdx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+ import Wide from "../../../components/Wide.astro";
4
+ import Accordion from "../../../components/Accordion.astro";
5
+ import diversityGridImg from "../../assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_grid_15x10.jpg";
6
+
7
+ ## Data and Diversity
8
+
9
+ Raw episodes are only the beginning. What you do with them before training determines whether your model learns to fold or learns to fumble.
10
+
11
+ We collected two datasets: a larger dataset containing all episodes, and a curated high-quality dataset which is partly a subset of the larger one, with additional high-quality episodes.
12
+
13
+ ### Dataset Statistics
14
+
15
+ | Metric | Large dataset | High-quality dataset |
16
+ |:---|:---:|:---:|
17
+ | Total episodes | **5,688** | **1,200** |
18
+ | Total frames | **14.1M** | **3.2M** |
19
+ | Total hours | **~131 h** | **~30 h** |
20
+ | FPS | **30** | **30** |
21
+ | Cameras | **3** (base 480×640, wrists 720×1280) | **3** (same) |
22
+ | Action dims | **16** (7 joints + gripper × 2 arms) | **16** (same) |
23
+
24
+ All data is stored in the [**LeRobotDataset v3.0**](https://huggingface.co/blog/lerobot-datasets-v3) format, which encodes camera streams as video rather than individual images. This makes the dataset almost **10x more compressed** compared to storing raw frames, making it practical to share and stream datasets of this scale.
25
+
26
+ ### Trajectory Diversity Grid
27
+
28
+ The grid below shows one frame from each of 100 different episodes. Notice the variation in t-shirt color, background, camera viewpoint, and robot height.
29
+
30
+ <Wide>
31
+ <img src={diversityGridImg.src} alt="Trajectory diversity grid showing variation in t-shirt color, background, camera viewpoint, and robot height" style="width:100%; border-radius: 8px;" />
32
+ </Wide>
33
+
34
+ ### Data Augmentation and Curation
35
+
36
+ #### Filtering
37
+
38
+ We filtered episodes in two ways:
39
+
40
+ 1. **End-state image filtering** discard episodes where the final frame doesn't show a properly folded shirt. If the end result isn't good, the demonstration isn't useful.
41
+ 2. **Length-based filtering** using the LeRobot data visualizer to remove outliers. Episodes that are suspiciously short tend to be low quality.
42
+
43
+ The [LeRobot Data Visualizer](https://huggingface.co/spaces/lerobot/visualize_dataset) was invaluable for inspecting the dataset, spotting outliers, and understanding distributions. If you're collecting robot data, use it you can try it right here with our dataset:
44
+
45
+ <Wide>
46
+ <div className="card" style="overflow: hidden; border-radius: 10px;">
47
+ <iframe src="https://lerobot-visualize-dataset.hf.space/?path=%2Flerobot%2Fhigh_quality_folding%2Fepisode_0" width="100%" height="800" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" style="border: none;"></iframe>
48
+ </div>
49
+ </Wide>
50
+
51
+ #### SARM Annotation with RABC
52
+
53
+ We also annotated every episode using our trained **[SARM](https://huggingface.co/docs/lerobot/sarm)** reward model. This gave us continuous scores we could weight during training. More details in [SARM: Our Reward Model](#sarm-our-reward-model).
app/src/content/chapters/folding/06-training.mdx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+ import Accordion from "../../../components/Accordion.astro";
4
+ import Wide from "../../../components/Wide.astro";
5
+ import HtmlEmbed from "../../../components/HtmlEmbed.astro";
6
+
7
+ ## Training
8
+
9
+ Before we can talk about hyperparameters, we need to understand what the model actually *is* what it takes in, what it produces, and why those choices matter for cloth folding.
10
+
11
+ ### Model Architecture
12
+
13
+ At its core, the model is a **Vision-Language-Action (VLA)** model. It sees the world through cameras, understands a task description, and outputs motor commands 30 timesteps of joint angle targets and gripper commands, generated via flow matching at 30 Hz.
14
+
15
+ <Wide>
16
+ <HtmlEmbed
17
+ id="pi05-sarm-arch"
18
+ src="folding/pi05-sarm-architecture.html"
19
+ title="π0.5 + SARM Architecture"
20
+ desc="Interactive system overview. <strong>Full System</strong> shows how the π0.5 VLA and SARM reward model work together. <strong>π0.5 Detail</strong> zooms into the VLM backbone, attention pattern, and flow matching action expert. <strong>SARM Detail</strong> shows the two-transformer reward model with stage classification and subtask progress prediction. Hover any component for details."
21
+ frameless
22
+ />
23
+ </Wide>
24
+
25
+ The model generates actions through **flow matching** a generative approach that transforms random noise into coherent action sequences, conditioned on what the cameras see and what the joints are doing. This allows the model to represent **multi-modal action distributions**: when there are multiple valid ways to grasp a sleeve or start a fold, the model can capture that ambiguity rather than averaging to a meaningless middle ground.
26
+
27
+ <Sidenote>
28
+ Flow matching is closely related to diffusion models but uses a simpler, more direct interpolation path between noise and data.
29
+ </Sidenote>
30
+
31
+ #### [Real-Time Chunking (RTC)](https://huggingface.co/docs/lerobot/rtc)
32
+
33
+ A crucial detail for real-world deployment: the model predicts action chunks of 30 steps, but instead of waiting for one chunk to finish before generating the next, RTC generates the next chunk while executing the current one. It "freezes" actions that are guaranteed to execute and "inpaints" the rest, enabling smooth asynchronous execution, speeding up our rollouts by at least a factor of 2.
34
+
35
+ ```mermaid
36
+ sequenceDiagram
37
+ participant R as Robot
38
+ participant M as Model
39
+ loop Every execution_horizon steps
40
+ R->>M: Current observation
41
+ M->>R: Action chunk (30 steps)
42
+ Note over R: Execute while next chunk generates
43
+ end
44
+ ```
45
+
46
+ ### Models
47
+
48
+ We initially trained multiple architectures supported in LeRobot, but we ended up training two VLA architectures on our cloth folding data:
49
+
50
+ - **π0** the base flow-matching VLA, trained with standard imitation learning
51
+ - **[π0.5](https://huggingface.co/docs/lerobot/pi05)** an improved variant with more pretraining and some additional improvements to the flow matching denoising process
52
+
53
+ Both are finetuned from pretrained checkpoints. Starting from this pretrained foundation, rather than training from scratch gives the model a head start on visual understanding and basic manipulation concepts.
54
+
55
+ ### Training Setup
56
+
57
+ | Parameter | Value |
58
+ |:---|:---:|
59
+ | GPUs | **8x H100** |
60
+ | Batch size | **32** (with gradient accumulation), total batch size is 256 |
61
+ | Action chunk size | **30** |
62
+ | Optimizer | AdamW |
63
+ | Learning rate | **1e-4** (with warmup + cosine decay) |
64
+ | Training steps | **200k** (Series 1) / **100k** (Series 2 fine-tune) |
65
+
66
+ <Sidenote>
67
+ Multi-GPU training with 8x H100 and gradient accumulation was necessary to fit the large batch sizes needed for stable VLA training.
68
+ </Sidenote>
69
+
70
+ ### Loss Curves
71
+
72
+ <Wide>
73
+ <HtmlEmbed
74
+ id="loss-curves"
75
+ src="folding/loss-curves.html"
76
+ title="Training Loss"
77
+ desc="Click legend entries to toggle runs. <strong>Series 1</strong>: trained from pretrained checkpoints on full data (200k steps). <strong>Series 2</strong>: fine-tuned on curated high-quality data (100k steps)."
78
+ frameless
79
+ />
80
+ </Wide>
81
+
82
+ Our training followed two phases: **Series 1** trained from pretrained base checkpoints on the full dataset for 200k steps, then **Series 2** fine-tuned the best Series 1 checkpoint on curated high-quality data for 100k steps.
app/src/content/chapters/folding/07-evaluation.mdx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+ import Accordion from "../../../components/Accordion.astro";
4
+ import HtmlEmbed from "../../../components/HtmlEmbed.astro";
5
+
6
+ ## Evaluation
7
+
8
+ **Evaluation is as hard as training.** In robotics on real hardware, no standardized benchmarks exist. If your evaluation protocol is inconsistent, every downstream decision will be wrong.
9
+
10
+ ### Protocol
11
+
12
+ For every experiment we evaluate on:
13
+
14
+ - **5 different t-shirts for Level 1** (laid-out to fold)
15
+ - **5 different t-shirts for Level 2** (messy to spread to fold, then place aside)
16
+
17
+ Each t-shirt is attempted **twice consecutively**, giving **10 rollouts per level** and **20 rollouts total per experiment**. Every evaluation is filmed and scored from video afterward, so judgment is decoupled from execution.
18
+
19
+ <Note>
20
+ The eval protocol t-shirts, attempt count, scoring rubric, and filming setup is identical across every experiment.
21
+ </Note>
22
+
23
+ ### Metrics
24
+
25
+ We report four complementary metrics:
26
+
27
+ **1. Success Rate** Binary pass/fail per rollout.
28
+ **2. Score** Partial credit based on subtasks completed. This distinguishes a model that consistently reaches Fold 3 from one that fails at Unfold, even if neither achieves full success.
29
+
30
+ <Accordion title="Scoring rubric Level 1 and Level 2">
31
+
32
+ **Level 1** Laid-out shirt to fold (shirt starts flat):
33
+
34
+ | Subtask | Points |
35
+ |:---|:---:|
36
+ | Do first horizontal fold | +10 |
37
+ | Do second horizontal fold | +10 |
38
+ | Do third vertical fold | +10 |
39
+ | Do final fold | +10 |
40
+ | Rotate | +10 |
41
+ | **Maximum per rollout** | **50** |
42
+
43
+ **Level 2** Messy shirt to spread, fold, and place aside:
44
+
45
+ | Subtask | Points |
46
+ |:---|:---:|
47
+ | Unfold (spread the shirt) | +50 |
48
+ | Fold 1 | +10 |
49
+ | Fold 2 | +10 |
50
+ | Fold 3 | +10 |
51
+ | Fold 4 | +10 |
52
+ | Rotation + Place aside | +10 |
53
+ | **Maximum per rollout** | **100** |
54
+
55
+ Scores are summed across all rollouts in an experiment. With 10 L1 rollouts (max 50 points each) and 10 L2 rollouts (max 100 points each), the **maximum total score per experiment is 1,500 points**.
56
+
57
+ </Accordion>
58
+
59
+ **3. Fold quality** A 1–5 rating of the final fold appearance, averaged across successful rollouts.
60
+
61
+ **4. Completion time** Seconds to complete Level 1/Level 2, averaged across successful rollouts.
62
+
63
+ ### Statistical uncertainty
64
+
65
+ With 20 rollouts per experiment, even large apparent differences can be statistically indistinguishable. We report **Wilson 90% confidence intervals** on all success rates and run formal pairwise significance tests. Running 50-100 rollouts per experiment would give tighter estimates but was not feasible for us across 11 experiments on real hardware.
app/src/content/chapters/folding/08-ablations.mdx ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+ import Wide from "../../../components/Wide.astro";
4
+ import Accordion from "../../../components/Accordion.astro";
5
+ import HtmlEmbed from "../../../components/HtmlEmbed.astro";
6
+ import sarmEp300 from "../../assets/image/lerobot-data-collection_level2_final_quality3_ep300_progress.gif";
7
+ import sarmEp2500 from "../../assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2500_progress.gif";
8
+ import sarmEp2200 from "../../assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2200_progress.gif";
9
+ import Stack from "../../../components/Stack.astro";
10
+
11
+ ## Experiments
12
+
13
+ We ran 11 experiments to understand what *actually* matters. **Series 1** trains from pretrained base checkpoints on the full dataset. **Series 2** finetunes Series 1 checkpoints on curated high-quality data (2.1–2.4 from 1.3, 2.5 from 1.7). One early lesson: **undertraining makes the policy shaky** make sure your model has converged before drawing conclusions.
14
+
15
+ <Wide>
16
+
17
+ | # | Model | Base data | Steps | Normalization | Key experiment |
18
+ |:---:|:---:|:---|:---:|:---:|:---|
19
+ | 1.1 | π0 | All data | 200k | MEAN_STD | Baseline |
20
+ | 1.2 | π0.5 | All data | 200k | MEAN_STD | Baseline |
21
+ | 1.3 | π0.5 | All data | 200k | QUANTILES | Δ (Delta) Action |
22
+ | 1.4 | π0.5 | All data | 200k | MEAN_STD | Reward model (SARM) with RABC κ=0.01 |
23
+ | 1.5 | π0.5 | All data | 200k | MEAN_STD | Reward model (SARM) with RABC κ=0.0215 |
24
+ | 1.7 | π0.5 | All data | 200k | QUANTILES | Δ (Delta) Action + Reward model (SARM) with RABC κ=0.0215 |
25
+ | 2.1 | π0.5 | High-quality only | 100k | QUANTILES | Fine-tune from 1.3 |
26
+ | 2.2 | π0.5 | High-quality only | 100k | QUANTILES | Fine-tune from 1.3 + Reward model (SARM) with RABC κ=0.0265 + Δ (Delta) Action |
27
+ | 2.3 | π0.5 | High-quality + mirrored | 100k | QUANTILES | Fine-tune from 1.3 + Δ (Delta) Action + image transforms + mirroring setup (data augmentation) |
28
+ | 2.4 | π0.5 | High-quality only | 100k | QUANTILES | Fine-tune from 1.3 · chunk=45 |
29
+ | 2.5 | π0.5 | High-quality only | 100k | QUANTILES | Fine-tune from 1.7 + Reward model (SARM) with RABC κ=0.0265 + Δ (Delta) Action |
30
+
31
+ </Wide>
32
+
33
+ All experiments use **[RTC](https://huggingface.co/docs/lerobot/rtc)** (Real-Time Chunking) and **action interpolation** (upsampling from 30 Hz to 90 Hz). The RTC settings used across all experiments:
34
+
35
+ ```python
36
+ policy_cfg.rtc_config = RTCConfig(
37
+ enabled=True,
38
+ execution_horizon=20,
39
+ max_guidance_weight=5.0,
40
+ prefix_attention_schedule=RTCAttentionSchedule.LINEAR,
41
+ )
42
+ ```
43
+
44
+ With an action queue size of 30 and max action horizon of 20. RTC gave us a ~2x speedup (sometimes even 2.5x), and action interpolation made the robot much quieter and smoother. Both are now available on [LeRobot main](https://github.com/huggingface/lerobot).
45
+
46
+ ### SARM: Our Reward Model
47
+
48
+ Before diving into the experiments further, let's introduce a key ingredient: **[SARM](https://huggingface.co/docs/lerobot/sarm)** (Stage-Aware Reward Modeling). SARM is a trained reward model that scores trajectories based on how well the robot is progressing toward task completion, it acts as a learned "critic" that predicts whether things are going well or badly.
49
+
50
+ SARM is trained on our demonstration data to predict 0-1 task progression. The key insight: it correctly identifies **mistakes** (drops in value) and **progress** (increases) in real time.
51
+
52
+ <Wide>
53
+ <Stack layout="3-column" gap="small">
54
+ <img src={sarmEp300.src} alt="SARM annotation on episode 300" style="width:100%; border-radius: 8px;" />
55
+ <img src={sarmEp2500.src} alt="SARM annotation on episode 2500" style="width:100%; border-radius: 8px;" />
56
+ <img src={sarmEp2200.src} alt="SARM annotation on episode 2200" style="width:100%; border-radius: 8px;" />
57
+ </Stack>
58
+ </Wide>
59
+
60
+ We use SARM exclusively for **RABC** (Reward-Advantage-Based Conditioning): it scores every episode with a per-timestep quality signal, and during training we weight actions by their contribution to progress. High-reward actions contribute more to the loss, low-reward ones contribute less. Negative progress are clipped to 0. Unlike binary success/fail labels, SARM provides continuous signal on every timestep.
61
+
62
+ ---
63
+
64
+ ### Results Overview
65
+
66
+ Now let's look at how each experiment actually performed. The charts below show success rates, scores, completion times, and failure modes across all 11 experiments. The pattern is consistent: **Series 2 dominates Series 1**, and within each series, RABC combined with delta actions produces the best results. Explore the charts, then we break down the key findings below.
67
+
68
+ <HtmlEmbed
69
+ id="success-rates"
70
+ src="folding/success-rates.html"
71
+ title="Success Rates by Experiment"
72
+ desc="Success rates (Total, Level 1, Level 2) across all experiments. Series 1 trains from scratch on full data; Series 2 finetunes the best Series 1 checkpoint on curated high-quality data."
73
+ />
74
+
75
+ The gap between Series 1 and Series 2 is immediately visible. Experiment 2.5 reaches 90% total success rate (100% L1, 80% L2), while the best Series 1 result tops out at 40%. No Series 1 experiment achieves a single Level 2 success.
76
+
77
+ <HtmlEmbed
78
+ id="total-score"
79
+ src="folding/total-score.html"
80
+ title="Total Score by Experiment"
81
+ desc="Overall score (% of maximum 1500) per experiment. The 50% threshold line highlights which experiments achieve at least half the maximum score."
82
+ />
83
+
84
+ Total score captures partial progress that binary success rate misses. Even failed rollouts earn credit for completed subtasks, revealing that some Series 1 experiments make meaningful progress despite 0% Level 2 success. Only two experiments break the 50% threshold, all from Series 2.
85
+
86
+ <HtmlEmbed
87
+ id="l1-time-quality"
88
+ src="folding/l1-time-quality.html"
89
+ title="Level 1 Completion Time & Fold Quality"
90
+ desc="Average Level 1 completion time (bars) and fold quality score (dashed line, right axis) per experiment. Lower time and higher quality are better."
91
+ />
92
+
93
+ Speed and quality correlate strongly with data quality. Series 2 experiments fold 2-3x faster than Series 1 (40s vs 100s+), and fold quality only breaks past 3.0 with high-quality training data. Faster isn't a separate goal from better; it's a consequence of the policy learning a clear, unambiguous strategy.
94
+
95
+ <HtmlEmbed
96
+ id="subtask-heatmap"
97
+ src="folding/subtask-heatmap.html"
98
+ title="Subtask Timing Heatmap"
99
+ desc="Average time (seconds) per subtask across all experiments. Green is fast, red is slow. Dashes indicate the subtask was never completed."
100
+ />
101
+
102
+ The heatmap shows where time is spent. Series 1 experiments are slow across the board, especially on Unfold. Series 2 compresses most subtasks to under 10 seconds, with the remaining time concentrated on Unfold for Level 2, which is inherently the most complex step.
103
+
104
+ ### Where the policies fail
105
+
106
+ Before interpreting success rates, it helps to understand *how* each experiment fails not just whether it fails.
107
+
108
+ <HtmlEmbed
109
+ id="failure-analysis"
110
+ src="folding/failure-analysis.html"
111
+ title="Failure Analysis"
112
+ desc="Breakdown of failure modes across experiments for Level 2, Level 1, and combined. Use the tabs to switch between levels. Each bar shows which subtask the policy failed at, revealing where different experiments struggle."
113
+ />
114
+
115
+ ### Which differences are real?
116
+
117
+ With 20 rollouts per experiment, not every visible gap is real. We run **Barnard's exact test** on all 55 pairs with **Bonferroni correction** (α = 0.10, per-pair p < 0.0018), following [TRI's statistical evaluation framework](https://medium.com/toyotaresearch/statistical-thinking-for-robot-policy-evaluation-from-rigorous-a-b-testing-to-effective-0ae886fbd68d). The chart below shows the full **Bayesian Beta posterior** over each policy's true success rate. **CLD letters** above each violin indicate which experiments are statistically separable, policies sharing a letter are not significantly different.
118
+
119
+ <HtmlEmbed
120
+ id="statistical-analysis"
121
+ src="folding/statistical-analysis.html"
122
+ title="Statistical Analysis"
123
+ desc="Bayesian posterior distributions over each policy's true success rate, with Compact Letter Display (CLD) groups summarising statistical separability. Toggle between Total, Level 1, and Level 2."
124
+ />
125
+
126
+ ---
127
+
128
+ ### Key Findings
129
+
130
+ #### 1. Data quality matters most
131
+
132
+ This is the finding we're most confident in it held regardless of which confidence level or correction method we used. The best Series 1 result (1.7) achieves 40% total SR. The best Series 2 result (2.5) achieves 90% using the *same architecture*. The pairwise tests cleanly separate these two groups, and no amount of algorithmic tuning within Series 1 came close to closing the gap.
133
+
134
+ We hypothesise that the root cause is the difference in **multi-modality** between the high-quality and full dataset. The full dataset contains demonstrations with some inconsistent strategies: different grips, unfolding sequences, and timing, while the high-quality dataset enforces a more unified, consistent protocol.
135
+
136
+ <Note variant="info" emoji="💡">
137
+ Define the exact task protocol before collecting data. Speed is secondary to consistency and clarity of intent at every step.
138
+ </Note>
139
+
140
+ #### 2. Delta actions improve performance consistently
141
+
142
+ Comparing π0.5 without delta actions (1.2: 20% total SR, 40% L1) to π0.5 with delta actions and quantile normalization (1.3: 35% total SR, 70% L1), and then to the full combination in 1.7 (40% total SR, 80% L1), shows that training with delta actions consistently improves performance. The trend is clear and shows up in every comparison we made.
143
+
144
+ The effect size doesn't separate cleanly at 20 rollouts, but the direction is consistent. **Caveat:** π0.5 is likely pretrained with delta actions, so 1.3 and 1.7 fine-tune in a regime consistent with pretraining, while 1.2 fine-tunes against it.
145
+
146
+ #### 3. RABC helps especially on long tasks like level 2
147
+
148
+ RABC on high-quality data produces the two best results overall: 2.2 and 2.5 clearly separate from experiments without it. The effect is strongest on **Level 2**, the longer and harder task — 2.2 reaches 50% L2 SR and 2.5 reaches 80%, while every experiment without RABC on clean data stays at 0%.
149
+ #### 4. Fine-tuning from a strong checkpoint is the winning recipe
150
+
151
+ The best results share the same recipe: fine-tune a Series 1 checkpoint on curated high-quality data with RABC and delta actions.
152
+
153
+ | Experiment | Total SR | L1 SR | L2 SR | Recipe |
154
+ |:---:|:---:|:---:|:---:|:---|
155
+ | 2.5 | **90%** | **100%** | **80%** | 1.7 → HQ + RABC, 100k steps |
156
+ | 2.2 | 75% | 100% | 50% | 1.3 → HQ + RABC, 100k steps |
157
+ | 1.7 | 40% | 80% | 0% | All data, ΔActions + RABC + QUANTILES |
158
+
159
+ The jump from Series 1 to Series 2 is unambiguous in the statistical analysis — 2.5 and 2.2 clearly separate from the Series 1 group. The Series 1 checkpoint already knows how to fold shirts in general, the high-quality data teaches the correct protocol, and RABC emphasizes the best demonstrations within an already clean dataset.
160
+
161
+ Both 2.2 and 2.5 were trained for 100k steps. 2.2 fine-tunes from 1.3 while 2.5 fine-tunes from 1.7 (the stronger base). The difference (75% → 90%) likely reflects this stronger starting point. They don't separate from each other in the pairwise tests, suggesting the recipe itself (HQ + RABC + ΔActions) is the key ingredient, with the base checkpoint providing an additional boost.
162
+
163
+ #### 5. Level 2 requires everything to be right simultaneously
164
+
165
+ Every Series 1 experiment achieves exactly **0% Level 2 success**. Level 2 only becomes tractable in Series 2, and only with RABC on high-quality data (2.2: 50% L2, 2.5: 80% L2). The 0% → 50–80% jump is as clean a signal as you'll find in a 20-rollout experiment. Level 2 is genuinely harder it requires the policy to have seen consistent, high-quality demonstrations of the full task, because without a reliable starting state after unfolding, the subsequent folds can't succeed.
166
+
167
+ #### 6. Speed and fold quality both track data quality
168
+
169
+ Series 1 completes Level 1 in **78–122s**; Series 2 does it in **41–73s**. Fold quality (1–5 scale) hits a ceiling around 2.8 in Series 1, breaking past 3.0 only with high-quality data.
170
+
171
+ | Experiment | L1 Time | L1 SR | Quality |
172
+ |:---:|:---:|:---:|:---:|
173
+ | 1.1 (π0, all data) | 121.5s | 80% | 2.70 |
174
+ | 1.7 (best S1) | 99.5s | 80% | 2.30 |
175
+ | 2.1 (HQ finetune)* | 57.6s | 70% | 2.80 |
176
+ | 2.2 (HQ + RABC) | 43.2s | 100% | 3.30 |
177
+ | 2.5 (best overall) | **40.8s** | **100%** | **4.10** |
178
+
179
+ Policies trained on the full dataset learned hesitant motions; the high-quality dataset enforces deliberate, progress oriented actions. Faster completion isn't a separate goal from quality it's a consequence of a clear, unambiguous strategy.
180
+
181
+ #### 8. What did not work
182
+
183
+ - **Mirroring augmentation** (2.3): only 5% total SR. It doubles multimodality, making convergence much harder even at 100k steps.
app/src/content/chapters/folding/09-learnings.mdx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Note from "../../../components/Note.astro";
2
+ import Sidenote from "../../../components/Sidenote.astro";
3
+
4
+ ## Learnings
5
+
6
+ Running all these experiments taught us a lot some expected, some not. Here's what stuck.
7
+
8
+ ### What mattered most
9
+
10
+ Beyond the experiment findings above, several practical insights stood out:
11
+
12
+ - **Train a reward model.** [SARM](https://huggingface.co/docs/lerobot/sarm) gave us data scoring, advantage conditioning, and curation in one package. We recommend it even for tasks where you think manual filtering would suffice.
13
+ - **Invest in recording quality early.** More time upfront on clean, consistent recordings pays off more than extra volume.
14
+ - **Record at higher frequency.** We'd record at 50 fps if we did it again. Folding is dynamic and higher record rates capture transitions better.
15
+ - **DAgger is promising.** Targeted corrections for the model's actual failure modes should be very effective pushign the success rate higher. This infrastructure is ready and now also merged into LeRobot.
16
+
17
+ ### For the community: the order of operations
18
+
19
+ If you're training a policy for a new manipulation task with LeRobot, here's the sequence we'd recommend based on what we learned:
20
+
21
+ 1. **Define your task protocol first.** Before collecting a single episode, agree on exactly how the task should be performed.
22
+ 2. **Collect 50–100 clean demonstrations.** Quality over volume. Consistent technique, good camera angles, deliberate motions. This is your foundation, everything else builds on it.
23
+ 3. **Train a reward model.** Use [SARM](https://huggingface.co/docs/lerobot/sarm) to score your episodes and enable RABC during training. This lets the policy focus on the best demonstrations, especially important for longer tasks.
24
+ 4. **Train a baseline and watch it fail.** Film the rollouts. Understanding *how* and *where* it breaks tells you exactly what data to collect next.
25
+ 5. **Use DAgger for targeted improvement.** Once you have a model that mostly works, collect correction data for its specific failure modes.
26
+ 6. **Enable action interpolation and RTC.** This smooths transitions and speeds up execution by blending overlapping predictions and doing asynchronous execution.
27
+ 7. **Film every evaluation.** Metrics alone won't tell the full story. Video reveals subtle failure modes that success rate misses, and lets you score quality.
28
+
29
+ <Note variant="info">
30
+ All the innovations from this project [SARM](https://huggingface.co/docs/lerobot/sarm), [RTC](https://huggingface.co/docs/lerobot/rtc), DAgger, [Open Arms](https://huggingface.co/docs/lerobot/openarm), and Open Arms Mini are merged into [LeRobot main](https://github.com/huggingface/lerobot). You can use our full pipeline as a starting point and swap in your own task.
31
+ </Note>
32
+
33
+ ### What's next
34
+
35
+ This project is far from done. We're releasing the final model, full dataset, and all training configs on HF Hub. Here's where LeRobot is headed next:
36
+
37
+ - **Massive-scale training.** We want LeRobot and LeRobotDataset to support 10–100x the data we used here, with billions of frames, powered by the new [HF Buckets](https://huggingface.co/docs/hub/en/storage-buckets) for storage and streaming at scale.
38
+ - **More robots, teleoperators, VLAs, and reward models.** We're continuing to expand the ecosystem of supported hardware, teleoperation setups, and model architectures in LeRobot.
39
+ - **RL support.** Extending LeRobot with new reinforcement learning methods and all the infrastructure needed to train policies online, not just from offline demonstrations.
40
+ - **Democratize robot learning.** Continue to lower the barrier to entry and share every insight, tool, and method with the community.
41
+
42
+ We also encourage you to use our dataset directly. Train your own policies, try new architectures, experiment with different training recipes. If you find something promising, reach out we're happy to run your models on our physical setups and share the results back.
app/src/content/chapters/folding/12-references.mdx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import HfUser from '../../../components/HfUser.astro';
2
+ import Note from '../../../components/Note.astro';
3
+
4
+ ## Acknowledgments
5
+
6
+ That's the full story from beginning to where we are now. We hope this guide helps you build on what we've done, and push open-source robotics even further.
7
+
8
+ This project would not have been possible without the contributions and support of many people.
9
+
10
+ <div className="hf-user-list">
11
+ <HfUser username="pepijn223" name="Pepijn Kooijmans" />
12
+ <HfUser username="aractingi" name="Michel Aractingi" />
13
+ <HfUser username="imstevenpmwork" name="Steven Palma" />
14
+ <HfUser username="CarolinePascal" name="Caroline Pascal" />
15
+ <HfUser username="jadechoghari" name="Jade Choghari" />
16
+ <HfUser username="lilkm" name="Khalil Meftah" />
17
+ <HfUser username="nepyope" name="Martino Russi" />
18
+ <HfUser username="Nico-robot" name="Nicolas Rabault" />
19
+ <HfUser username="VirgileBatto" name="Virgile Batto" />
20
+ <HfUser username="thomwolf" name="Thomas Wolf" />
21
+ </div>
22
+
23
+ A special thank you to our robot operators, who spent countless hours patiently demonstrating shirt folds and collecting the high-quality data that made this entire project possible: **Shiyu Liu**, **Alison Magniez**, **Hamza Ben Taieb**, **Victor Gomez**, **Stéphane Combo**, **Domitille Bissery**, **Tiphaine de Cherisey**, **Nathalie Vi** and **Melaku Yemaneberhan**
24
+
25
+ ### Models & Datasets
26
+
27
+ - **Best model (Experiment 2.5)** [lerobot-data-collection/folding_final](https://huggingface.co/lerobot-data-collection/folding_final)
28
+ - **SARM reward model** [lerobot-data-collection/folding_sarm_reward](https://huggingface.co/lerobot-data-collection/folding_sarm_reward)
29
+ - **High-quality dataset** [lerobot/high_quality_folding](https://huggingface.co/datasets/lerobot/high_quality_folding)
30
+ - **Full dataset** [lerobot/full_folding](https://huggingface.co/datasets/lerobot/full_folding)
31
+
32
+ ### Papers
33
+
34
+ - **π0.5** Black et al. (2025). *A Vision-Language-Action Model with Open-World Generalization.* [pi.website/blog/pi05](https://www.pi.website/blog/pi05) · [LeRobot docs](https://huggingface.co/docs/lerobot/pi05)
35
+ - **RTC** Black, Galliker & Levine (2025). *Real-Time Execution of Action Chunking Flow Policies.* [pi.website/research/real_time_chunking](https://www.pi.website/research/real_time_chunking) · [LeRobot docs](https://huggingface.co/docs/lerobot/rtc)
36
+ - **SARM** Chen et al. (2025). *Stage-Aware Reward Modeling for Long Horizon Robot Manipulation.* [arxiv.org/abs/2509.25358](https://arxiv.org/abs/2509.25358) · [LeRobot docs](https://huggingface.co/docs/lerobot/sarm)
37
+ - **DAgger** Ross, Gordon & Bagnell (2011). *A Reduction of Imitation Learning and Structured Prediction to No-Regret Online Learning.* [arxiv.org/abs/1011.0686](https://arxiv.org/abs/1011.0686)
38
+
39
+ *PS if you want to use this format for writing your own blog, check out the [Research Article Template](https://huggingface.co/spaces/tfrere/research-article-template).*
app/src/content/chapters/your-first-chapter.mdx DELETED
@@ -1,2 +0,0 @@
1
- # this is an example chapter
2
-