Spaces:
Running
Running
Robot folding blog post — initial deployment
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- README.md +1 -1
- app/package-lock.json +0 -0
- app/package.json +0 -0
- app/src/components/Hero.astro +58 -1
- app/src/components/HtmlEmbed.astro +151 -5
- app/src/components/Image.astro +44 -24
- app/src/components/Reference.astro +2 -2
- app/src/components/Video.astro +123 -0
- app/src/components/trackio/TrackioWrapper.astro +11 -26
- app/src/components/trackio/components/Cell.svelte +182 -92
- app/src/components/trackio/components/FullscreenModal.svelte +194 -154
- app/src/components/trackio/core/adaptive-sampler.js +48 -48
- app/src/components/trackio/core/data-generator.js +110 -110
- app/src/components/trackio/renderers/ChartRendererRefactored.svelte +277 -83
- app/src/components/trackio/renderers/core/interaction-manager.js +119 -72
- app/src/components/trackio/renderers/core/path-renderer.js +105 -40
- app/src/components/trackio/renderers/core/zoom-manager.js +288 -0
- app/src/content/article.mdx +58 -26
- app/src/content/assets/audio/audio-example.mp3 +3 -0
- app/src/content/assets/image/Folding_V1.mp4 +3 -0
- app/src/content/assets/image/footpedal.jpg +3 -0
- app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2200_progress.gif +3 -0
- app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2500_progress.gif +3 -0
- app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_grid_15x10.jpg +3 -0
- app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_ep300_progress.gif +3 -0
- app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_grid_15x10.jpg +3 -0
- app/src/content/assets/image/maintain-the-unmaintainable.png +2 -2
- app/src/content/assets/image/ogp.webp +3 -0
- app/src/content/assets/image/openarm-mini1.jpg +3 -0
- app/src/content/assets/image/openarm-mini2.jpg +3 -0
- app/src/content/assets/image/robot_folding.png +3 -0
- app/src/content/assets/image/smoll-training-guide.png +2 -2
- app/src/content/bibliography.bib +87 -107
- app/src/content/chapters/demo/built-with-this.mdx +19 -10
- app/src/content/chapters/demo/components.mdx +1 -1
- app/src/content/chapters/demo/import-content.mdx +46 -21
- app/src/content/chapters/demo/markdown.mdx +2 -2
- app/src/content/chapters/demo/writing-your-content.mdx +1 -1
- app/src/content/chapters/folding/01-hero.mdx +35 -0
- app/src/content/chapters/folding/02-results.mdx +38 -0
- app/src/content/chapters/folding/03-hardware.mdx +79 -0
- app/src/content/chapters/folding/04-data-collection.mdx +28 -0
- app/src/content/chapters/folding/05-data-diversity.mdx +53 -0
- app/src/content/chapters/folding/06-training.mdx +82 -0
- app/src/content/chapters/folding/07-evaluation.mdx +65 -0
- app/src/content/chapters/folding/08-ablations.mdx +183 -0
- app/src/content/chapters/folding/09-learnings.mdx +42 -0
- app/src/content/chapters/folding/12-references.mdx +39 -0
- 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: '
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 100 |
-
|
| 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,8 +109,8 @@ const resolvedDownloadSrc = downloadSrc || originalSrc;
|
|
| 109 |
---
|
| 110 |
|
| 111 |
<div
|
| 112 |
-
class={`
|
| 113 |
-
data-
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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") !== "
|
| 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") === "
|
| 359 |
-
//
|
| 360 |
document
|
| 361 |
-
.querySelectorAll(".
|
| 362 |
.forEach((el) => el.classList.remove("zoom-active"));
|
| 363 |
|
| 364 |
-
// Add zoom-active to this
|
| 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) .
|
| 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) .
|
| 527 |
opacity: 1;
|
| 528 |
z-index: var(--z-overlay);
|
| 529 |
}
|
| 530 |
|
| 531 |
/* Fallback for browsers without :has() support */
|
| 532 |
-
:global(.medium-zoom--opened) .
|
| 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) .
|
| 552 |
opacity: 0;
|
| 553 |
z-index: calc(var(--z-base) - 1);
|
| 554 |
}
|
| 555 |
|
| 556 |
-
:global(.medium-zoom--opened) .
|
| 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 |
-
.
|
| 583 |
margin-top: 20px;
|
| 584 |
}
|
| 585 |
|
| 586 |
-
.
|
| 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"]) .
|
| 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(.
|
| 44 |
margin-bottom: 0;
|
| 45 |
}
|
| 46 |
|
| 47 |
-
.reference__content :global(.
|
| 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); //
|
| 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 |
-
//
|
| 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; //
|
| 239 |
|
| 240 |
console.log(`Starting simulation for run: ${currentSimulationRun}`);
|
| 241 |
|
| 242 |
-
//
|
| 243 |
startSimulationBtn.style.display = "none";
|
| 244 |
stopSimulationBtn.style.display = "inline-flex";
|
| 245 |
startSimulationBtn.disabled = true;
|
| 246 |
|
| 247 |
-
//
|
| 248 |
addSimulationStep();
|
| 249 |
|
| 250 |
-
//
|
| 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); //
|
| 260 |
}
|
| 261 |
|
| 262 |
// Function to add a new data point
|
|
@@ -274,7 +274,7 @@ import Trackio from "./Trackio.svelte";
|
|
| 274 |
newDataPoint,
|
| 275 |
);
|
| 276 |
|
| 277 |
-
//
|
| 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 |
-
//
|
| 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 |
-
//
|
| 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
|
| 3 |
-
import ChartTooltip from
|
| 4 |
-
import { formatAbbrev } from
|
| 5 |
-
|
| 6 |
// Props
|
| 7 |
export let metricKey;
|
| 8 |
export let titleText;
|
| 9 |
export let wide = false;
|
| 10 |
-
export let variant =
|
| 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) =>
|
| 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(
|
| 44 |
const { step, entries, position } = data;
|
| 45 |
-
|
| 46 |
if (entries.length) {
|
| 47 |
// Use global mouse coordinates for tooltip positioning
|
| 48 |
-
const trackioEl = hostEl.closest(
|
| 49 |
const trackioRect = trackioEl.getBoundingClientRect();
|
| 50 |
-
|
| 51 |
// Position tooltip near global cursor with small offset
|
| 52 |
-
const relativeX =
|
|
|
|
| 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(
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
// Dispatch to host for cross-cell synchronization
|
| 65 |
-
try {
|
| 66 |
-
hostEl &&
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
-
|
| 73 |
function handleChartLeave() {
|
| 74 |
tooltipVisible = false;
|
| 75 |
tooltipX = -9999;
|
| 76 |
tooltipY = -9999;
|
| 77 |
-
|
| 78 |
// Dispatch leave event
|
| 79 |
-
try {
|
| 80 |
-
hostEl &&
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
-
|
| 86 |
// External hover synchronization
|
| 87 |
function setupExternalHover() {
|
| 88 |
if (!root || root.__syncAttached || !hostEl) return;
|
| 89 |
-
|
| 90 |
-
hostEl.addEventListener(
|
| 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(
|
| 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 |
-
<
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
}
|
| 235 |
-
|
| 236 |
/* Dark mode: richer gradient for Oblivion */
|
| 237 |
:global([data-theme="dark"]) :global(.trackio.theme--oblivion .cell-bg) {
|
| 238 |
-
background:
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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
|
| 3 |
-
import ChartRenderer from
|
| 4 |
-
import ChartTooltip from
|
| 5 |
-
import Legend from
|
| 6 |
-
import { formatAbbrev } from
|
| 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) =>
|
| 14 |
-
export let variant =
|
| 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(
|
| 44 |
if (trackioParent) {
|
| 45 |
const computedStyle = getComputedStyle(trackioParent);
|
| 46 |
const cssVars = [
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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(
|
| 70 |
});
|
| 71 |
}
|
| 72 |
-
|
| 73 |
function closeModal() {
|
| 74 |
if (modalElement) {
|
| 75 |
-
modalElement.classList.remove(
|
| 76 |
setTimeout(() => {
|
| 77 |
if (modalElement && modalElement.parentNode) {
|
| 78 |
modalElement.parentNode.removeChild(modalElement);
|
| 79 |
}
|
| 80 |
-
dispatch(
|
| 81 |
}, 300);
|
| 82 |
}
|
| 83 |
}
|
| 84 |
-
|
| 85 |
function handleKeydown(e) {
|
| 86 |
-
if (e.key ===
|
| 87 |
closeModal();
|
| 88 |
-
} else if (e.key ===
|
| 89 |
navigatePrevious();
|
| 90 |
-
} else if (e.key ===
|
| 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(
|
| 154 |
if (legendItems && legendItems[otherIdx]) {
|
| 155 |
-
legendItems[otherIdx].classList.add(
|
| 156 |
}
|
| 157 |
-
|
| 158 |
-
const chartElements = modalElement?.querySelectorAll(
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
});
|
| 162 |
-
|
| 163 |
// Add hovering class to trigger the ghost styles
|
| 164 |
-
const modalChart = modalElement?.querySelector(
|
| 165 |
-
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
-
|
| 168 |
function handleLegendLeave() {
|
| 169 |
-
const legendItems = modalElement?.querySelectorAll(
|
| 170 |
-
legendItems?.forEach(item => item.classList.remove(
|
| 171 |
-
|
| 172 |
-
const chartElements = modalElement?.querySelectorAll(
|
| 173 |
-
chartElements?.forEach(el => el.classList.remove(
|
| 174 |
-
|
| 175 |
// Remove hovering class
|
| 176 |
-
const modalChart = modalElement?.querySelector(
|
| 177 |
-
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
</script>
|
| 180 |
|
| 181 |
<!-- Modal overlay -->
|
| 182 |
{#if visible}
|
| 183 |
-
<div
|
| 184 |
bind:this={modalElement}
|
| 185 |
-
class="trackio-modal-overlay trackio {variant === 'oblivion'
|
|
|
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
| 253 |
on:legend-leave={handleLegendLeave}
|
| 254 |
/>
|
| 255 |
</div>
|
| 256 |
{/if}
|
| 257 |
-
|
| 258 |
<!-- Chart -->
|
| 259 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"])
|
|
|
|
| 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 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
backdrop-filter: blur(10px);
|
| 507 |
}
|
| 508 |
-
|
| 509 |
/* Dark mode oblivion modal */
|
| 510 |
-
:global([data-theme="dark"])
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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"])
|
|
|
|
| 589 |
color: #ffffff;
|
| 590 |
}
|
| 591 |
-
|
| 592 |
-
:global([data-theme="dark"])
|
|
|
|
| 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"])
|
|
|
|
| 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"])
|
|
|
|
| 609 |
background: transparent;
|
| 610 |
border: none;
|
| 611 |
color: #ffffff;
|
| 612 |
}
|
| 613 |
-
|
| 614 |
-
:global([data-theme="dark"])
|
|
|
|
| 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 -
|
| 92 |
-
*
|
| 93 |
*/
|
| 94 |
smartSampling(data) {
|
| 95 |
const targetPoints = this.options.targetPoints;
|
| 96 |
const features = this.detectFeatures(data);
|
| 97 |
-
|
| 98 |
-
//
|
| 99 |
const criticalPoints = new Set([0, data.length - 1]);
|
| 100 |
-
|
| 101 |
-
//
|
| 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 |
-
//
|
| 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 |
-
//
|
| 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 -
|
| 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 |
-
//
|
| 140 |
const visibleTargetPoints = Math.floor(this.options.targetPoints * 0.7);
|
| 141 |
const contextTargetPoints = this.options.targetPoints - visibleTargetPoints;
|
| 142 |
-
|
| 143 |
-
//
|
| 144 |
const visibleSample = this.smartSampling(viewData);
|
| 145 |
-
|
| 146 |
-
//
|
| 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 |
-
//
|
| 156 |
const combinedData = [
|
| 157 |
...beforeSample.data,
|
| 158 |
...visibleSample.data,
|
|
@@ -174,7 +174,7 @@ export class AdaptiveSampler {
|
|
| 174 |
}
|
| 175 |
|
| 176 |
/**
|
| 177 |
-
*
|
| 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 |
-
//
|
| 191 |
if (current > prev && current > next) {
|
| 192 |
-
//
|
| 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 |
-
//
|
| 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 |
-
//
|
| 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 |
-
*
|
| 227 |
*/
|
| 228 |
generateLogSpacing(totalLength, count) {
|
| 229 |
const indices = [];
|
| 230 |
for (let i = 1; i <= count; i++) {
|
| 231 |
const progress = i / (count + 1);
|
| 232 |
-
//
|
| 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 |
-
//
|
| 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 =
|
| 253 |
const avgNeighbors = (prev + next) / 2;
|
| 254 |
const variation = Math.abs(curr - avgNeighbors);
|
| 255 |
-
|
| 256 |
variations.push({ index: i, variation });
|
| 257 |
}
|
| 258 |
-
|
| 259 |
-
//
|
| 260 |
variations.sort((a, b) => b.variation - a.variation);
|
| 261 |
-
|
| 262 |
return variations.slice(0, targetPoints).map(v => v.index);
|
| 263 |
}
|
| 264 |
|
| 265 |
/**
|
| 266 |
-
*
|
| 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 |
-
*
|
| 291 |
*/
|
| 292 |
getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
|
| 293 |
// This method would allow recovering more details
|
| 294 |
-
//
|
| 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 |
-
*
|
| 304 |
*/
|
| 305 |
export const trackioSampler = new AdaptiveSampler({
|
| 306 |
maxPoints: 400,
|
|
@@ -310,7 +310,7 @@ export const trackioSampler = new AdaptiveSampler({
|
|
| 310 |
});
|
| 311 |
|
| 312 |
/**
|
| 313 |
-
*
|
| 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
|
| 46 |
-
//
|
| 47 |
if (rand < 0.05) {
|
| 48 |
-
// 5% -
|
| 49 |
return Random.intBetween(5, 50);
|
| 50 |
} else if (rand < 0.15) {
|
| 51 |
-
// 10% -
|
| 52 |
return Random.intBetween(50, 200);
|
| 53 |
} else if (rand < 0.35) {
|
| 54 |
-
// 20% -
|
| 55 |
return Random.intBetween(200, 400);
|
| 56 |
} else if (rand < 0.55) {
|
| 57 |
-
// 20% -
|
| 58 |
return Random.intBetween(400, 800);
|
| 59 |
} else if (rand < 0.75) {
|
| 60 |
-
// 20% - Long
|
| 61 |
return Random.intBetween(800, 1500);
|
| 62 |
} else if (rand < 0.90) {
|
| 63 |
-
// 15% -
|
| 64 |
return Random.intBetween(1500, 3000);
|
| 65 |
} else if (rand < 0.98) {
|
| 66 |
-
// 8% -
|
| 67 |
return Random.intBetween(3000, 5000);
|
| 68 |
} else {
|
| 69 |
-
// 2% - Massive
|
| 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 |
-
//
|
| 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 |
-
//
|
| 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
|
| 3 |
-
import
|
| 4 |
-
import {
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 7 |
-
import {
|
| 8 |
-
import {
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
// Props - same as original ChartRenderer
|
| 11 |
export let metricData = {};
|
| 12 |
export let rawMetricData = {};
|
| 13 |
-
export let colorForRun = (name) =>
|
| 14 |
-
export let variant =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 82 |
const maxSize = Math.max(0, ...runSizes);
|
| 83 |
needsSampling = maxSize > 400;
|
| 84 |
-
|
| 85 |
if (needsSampling) {
|
| 86 |
-
console.log(
|
| 87 |
-
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
if (!processedData.hasData) {
|
| 119 |
const { root } = svgManager.getGroups();
|
| 120 |
-
root.style(
|
| 121 |
return;
|
| 122 |
}
|
| 123 |
-
|
| 124 |
const { root } = svgManager.getGroups();
|
| 125 |
-
root.style(
|
| 126 |
-
|
| 127 |
// Update scales based on log scale setting
|
| 128 |
svgManager.initializeScales(logScaleX);
|
| 129 |
-
|
| 130 |
// Setup scales and domains
|
| 131 |
-
const { stepIndex } = ChartTransforms.setupScales(
|
| 132 |
-
|
| 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(
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
// Render grid
|
| 142 |
-
gridRenderer.renderGrid(
|
| 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(
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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(
|
| 225 |
-
window.addEventListener(
|
| 226 |
-
|
| 227 |
-
cleanup = () => {
|
| 228 |
if (ro) ro.disconnect();
|
| 229 |
if (resizeTimeout) clearTimeout(resizeTimeout);
|
| 230 |
-
window.removeEventListener(
|
| 231 |
-
window.removeEventListener(
|
| 232 |
if (svgManager) svgManager.destroy();
|
| 233 |
if (interactionManager) interactionManager.destroy();
|
|
|
|
| 234 |
};
|
| 235 |
});
|
| 236 |
-
|
| 237 |
onDestroy(() => {
|
| 238 |
cleanup && cleanup();
|
| 239 |
});
|
| 240 |
</script>
|
| 241 |
|
| 242 |
-
<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 |
-
//
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
.
|
| 44 |
-
|
| 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
|
| 197 |
-
const
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: "
|
| 3 |
-
subtitle: "
|
| 4 |
-
description: "
|
| 5 |
authors:
|
| 6 |
-
- name: "
|
| 7 |
-
url: "https://huggingface.co/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
affiliations: [1]
|
| 9 |
affiliations:
|
| 10 |
- name: "Hugging Face"
|
| 11 |
url: "https://huggingface.co"
|
| 12 |
-
published: "
|
| 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 |
-
-
|
| 19 |
-
-
|
|
|
|
|
|
|
| 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 |
-
<
|
| 40 |
|
| 41 |
-
<
|
| 42 |
|
| 43 |
-
<
|
| 44 |
|
| 45 |
-
<
|
| 46 |
|
| 47 |
-
<
|
| 48 |
|
| 49 |
-
<
|
| 50 |
|
| 51 |
-
<
|
| 52 |
|
| 53 |
-
<
|
| 54 |
|
| 55 |
-
<
|
| 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
|
app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2200_progress.gif
ADDED
|
Git LFS Details
|
app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_ep2500_progress.gif
ADDED
|
Git LFS Details
|
app/src/content/assets/image/lerobot-data-collection_level12_rac_2_2026-02-08_1_grid_15x10.jpg
ADDED
|
Git LFS Details
|
app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_ep300_progress.gif
ADDED
|
Git LFS Details
|
app/src/content/assets/image/lerobot-data-collection_level2_final_quality3_grid_15x10.jpg
ADDED
|
Git LFS Details
|
app/src/content/assets/image/maintain-the-unmaintainable.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
app/src/content/assets/image/ogp.webp
ADDED
|
Git LFS Details
|
app/src/content/assets/image/openarm-mini1.jpg
ADDED
|
Git LFS Details
|
app/src/content/assets/image/openarm-mini2.jpg
ADDED
|
Git LFS Details
|
app/src/content/assets/image/robot_folding.png
ADDED
|
Git LFS Details
|
app/src/content/assets/image/smoll-training-guide.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
app/src/content/bibliography.bib
CHANGED
|
@@ -1,130 +1,110 @@
|
|
| 1 |
-
@
|
| 2 |
-
title = {
|
| 3 |
-
author = {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
year = {2017}
|
| 7 |
}
|
| 8 |
|
| 9 |
-
@
|
| 10 |
-
title = {
|
| 11 |
-
author = {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
edition = {2},
|
| 16 |
-
isbn = {978-1491957660}
|
| 17 |
}
|
| 18 |
|
| 19 |
-
@
|
| 20 |
-
title = {
|
| 21 |
-
author = {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
doi = {10.1109/CVPR.2016.90},
|
| 26 |
-
url = {https://doi.org/10.1109/CVPR.2016.90}
|
| 27 |
}
|
| 28 |
|
| 29 |
-
@
|
| 30 |
-
title
|
| 31 |
-
author
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 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 |
-
@
|
| 43 |
-
title
|
| 44 |
-
author
|
| 45 |
-
|
| 46 |
-
year
|
| 47 |
-
|
| 48 |
-
archiveprefix = {arXiv},
|
| 49 |
-
eprint = {2303.08774},
|
| 50 |
-
primaryclass = {cs.CL},
|
| 51 |
-
url = {https://arxiv.org/abs/2303.08774}
|
| 52 |
}
|
| 53 |
|
| 54 |
-
@
|
| 55 |
-
title = {
|
| 56 |
-
author = {
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
doi = {10.5555/mit-2020-xyz}
|
| 61 |
}
|
| 62 |
|
| 63 |
-
@
|
| 64 |
-
title = {
|
| 65 |
-
author = {
|
| 66 |
-
booktitle = {
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
edition = {2},
|
| 70 |
-
year = {2006},
|
| 71 |
-
pages = {13--55},
|
| 72 |
-
isbn = {978-0471241959}
|
| 73 |
}
|
| 74 |
|
| 75 |
-
@
|
| 76 |
-
title
|
| 77 |
-
author
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
doi = {10.5281/zenodo.1234567},
|
| 81 |
-
url = {https://doi.org/10.5281/zenodo.1234567},
|
| 82 |
-
note = {Accessed 2025-09-01}
|
| 83 |
}
|
| 84 |
|
| 85 |
-
@
|
| 86 |
-
title
|
| 87 |
-
author
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
-
@
|
| 95 |
-
title
|
| 96 |
-
author
|
| 97 |
-
|
| 98 |
-
|
| 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{
|
| 110 |
-
title
|
| 111 |
-
author
|
| 112 |
-
journal
|
| 113 |
-
year
|
| 114 |
-
archiveprefix = {arXiv},
|
| 115 |
-
eprint = {1412.6980},
|
| 116 |
-
primaryclass = {cs.LG},
|
| 117 |
-
url = {https://arxiv.org/abs/1412.6980}
|
| 118 |
}
|
| 119 |
|
| 120 |
-
@
|
| 121 |
-
title
|
| 122 |
-
author
|
| 123 |
-
|
| 124 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 15 |
<Image
|
| 16 |
-
src={
|
| 17 |
-
alt="
|
|
|
|
|
|
|
| 18 |
/>
|
| 19 |
<div class="card-title-container">
|
| 20 |
-
<h3 class="card-title">
|
| 21 |
-
<p class="card-subtitle">A
|
| 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 |
-
|
|
|
|
| 37 |
<Image
|
| 38 |
-
src={
|
| 39 |
-
alt="
|
|
|
|
|
|
|
| 40 |
/>
|
| 41 |
<div class="card-title-container">
|
| 42 |
-
<h3 class="card-title">
|
| 43 |
-
<p class="card-subtitle">A
|
| 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.
|
| 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 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
```bash
|
| 47 |
cd app/scripts/notion-importer/
|
| 48 |
npm install
|
| 49 |
cp env.example .env
|
| 50 |
-
# Edit .env with
|
| 51 |
-
|
| 52 |
-
node index.mjs
|
| 53 |
```
|
| 54 |
|
|
|
|
|
|
|
| 55 |
### What Gets Converted
|
| 56 |
|
| 57 |
-
- Images
|
| 58 |
-
- Callouts → `<
|
| 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.
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|