Spaces:
Running
Running
| /* ======================================== | |
| G1 Moves — Showcase App | |
| ======================================== */ | |
| (function () { | |
| 'use strict'; | |
| // ------------------------------------ | |
| // State | |
| // ------------------------------------ | |
| let allClips = []; | |
| let baseUrl = ''; | |
| let activeCategory = 'all'; | |
| const isMobile = window.matchMedia('(max-width: 480px)').matches; | |
| const PAGE_SIZE = 12; | |
| let currentPage = 0; | |
| // ------------------------------------ | |
| // DOM refs | |
| // ------------------------------------ | |
| const grid = document.getElementById('clip-grid'); | |
| const emptyState = document.getElementById('empty-state'); | |
| const modal = document.getElementById('pipeline-modal'); | |
| // ------------------------------------ | |
| // Init | |
| // ------------------------------------ | |
| async function init() { | |
| try { | |
| const res = await fetch('data.json'); | |
| const data = await res.json(); | |
| allClips = data.clips; | |
| baseUrl = data.base_url; | |
| updateStats(data.stats); | |
| filterAndRender(); | |
| setupCategoryButtons(); | |
| setupModal(); | |
| setupScrollReveal(); | |
| } catch (err) { | |
| grid.innerHTML = '<p style="padding:2rem;color:#888;font-family:monospace;">Failed to load data.json</p>'; | |
| } | |
| } | |
| // ------------------------------------ | |
| // Stats | |
| // ------------------------------------ | |
| function updateStats(stats) { | |
| setText('stat-total', stats.total); | |
| setText('stat-dance', stats.dance); | |
| setText('stat-karate', stats.karate); | |
| setText('stat-bonus', stats.bonus); | |
| setText('stat-policies', stats.policies); | |
| } | |
| function setText(id, value) { | |
| const el = document.getElementById(id); | |
| if (el) el.textContent = value; | |
| } | |
| // ------------------------------------ | |
| // Filtering & Rendering | |
| // ------------------------------------ | |
| let filteredClips = []; | |
| let carouselIndex = 0; | |
| function filterAndRender() { | |
| filteredClips = activeCategory === 'all' | |
| ? allClips.slice() | |
| : allClips.filter(c => c.category === activeCategory); | |
| // Prioritize trained models first | |
| filteredClips.sort(function (a, b) { | |
| if (a.has_policy && !b.has_policy) return -1; | |
| if (!a.has_policy && b.has_policy) return 1; | |
| return 0; | |
| }); | |
| grid.innerHTML = ''; | |
| currentPage = 0; | |
| emptyState.classList.toggle('visible', filteredClips.length === 0); | |
| // Remove any existing pagination | |
| var oldNav = document.getElementById('page-nav'); | |
| if (oldNav) oldNav.remove(); | |
| if (isMobile) { | |
| carouselIndex = 0; | |
| renderCarousel(); | |
| } else { | |
| renderPage(); | |
| } | |
| } | |
| function totalPages() { | |
| return Math.ceil(filteredClips.length / PAGE_SIZE); | |
| } | |
| function renderPage() { | |
| grid.innerHTML = ''; | |
| var start = currentPage * PAGE_SIZE; | |
| var end = Math.min(start + PAGE_SIZE, filteredClips.length); | |
| var pageClips = filteredClips.slice(start, end); | |
| pageClips.forEach(function (clip, i) { | |
| var card = createCard(clip, start + i); | |
| grid.appendChild(card); | |
| }); | |
| observeLazyImages(); | |
| observeCardReveal(); | |
| updatePageNav(); | |
| // Scroll grid into view on page change | |
| if (currentPage > 0) { | |
| grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| } | |
| function updatePageNav() { | |
| var oldNav = document.getElementById('page-nav'); | |
| if (oldNav) oldNav.remove(); | |
| var pages = totalPages(); | |
| if (pages <= 1) return; | |
| var nav = document.createElement('div'); | |
| nav.id = 'page-nav'; | |
| nav.className = 'page-nav'; | |
| // Prev button | |
| var prevBtn = document.createElement('button'); | |
| prevBtn.className = 'page-nav-btn'; | |
| prevBtn.disabled = currentPage === 0; | |
| prevBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>'; | |
| prevBtn.setAttribute('aria-label', 'Previous page'); | |
| prevBtn.addEventListener('click', function () { | |
| if (currentPage > 0) { currentPage--; renderPage(); } | |
| }); | |
| // Counter | |
| var counter = document.createElement('span'); | |
| counter.className = 'page-nav-counter'; | |
| counter.textContent = (currentPage + 1) + ' / ' + pages; | |
| // Next button | |
| var nextBtn = document.createElement('button'); | |
| nextBtn.className = 'page-nav-btn'; | |
| nextBtn.disabled = currentPage >= pages - 1; | |
| nextBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>'; | |
| nextBtn.setAttribute('aria-label', 'Next page'); | |
| nextBtn.addEventListener('click', function () { | |
| if (currentPage < pages - 1) { currentPage++; renderPage(); } | |
| }); | |
| nav.appendChild(prevBtn); | |
| nav.appendChild(counter); | |
| nav.appendChild(nextBtn); | |
| grid.parentNode.insertBefore(nav, grid.nextSibling); | |
| } | |
| // ------------------------------------ | |
| // Mobile carousel | |
| // ------------------------------------ | |
| function renderCarousel() { | |
| grid.innerHTML = ''; | |
| if (!filteredClips.length) return; | |
| var wrap = document.createElement('div'); | |
| wrap.className = 'carousel'; | |
| // Counter | |
| var counter = document.createElement('div'); | |
| counter.className = 'carousel-counter'; | |
| wrap.appendChild(counter); | |
| // Card container | |
| var cardWrap = document.createElement('div'); | |
| cardWrap.className = 'carousel-card-wrap'; | |
| wrap.appendChild(cardWrap); | |
| // Nav buttons | |
| var nav = document.createElement('div'); | |
| nav.className = 'carousel-nav'; | |
| var prevBtn = document.createElement('button'); | |
| prevBtn.className = 'carousel-btn'; | |
| prevBtn.innerHTML = '‹'; | |
| prevBtn.setAttribute('aria-label', 'Previous'); | |
| var nextBtn = document.createElement('button'); | |
| nextBtn.className = 'carousel-btn'; | |
| nextBtn.innerHTML = '›'; | |
| nextBtn.setAttribute('aria-label', 'Next'); | |
| nav.appendChild(prevBtn); | |
| nav.appendChild(nextBtn); | |
| wrap.appendChild(nav); | |
| grid.appendChild(wrap); | |
| function showCard(idx) { | |
| carouselIndex = idx; | |
| var clip = filteredClips[idx]; | |
| counter.textContent = (idx + 1) + ' / ' + filteredClips.length; | |
| prevBtn.disabled = idx === 0; | |
| nextBtn.disabled = idx === filteredClips.length - 1; | |
| cardWrap.innerHTML = ''; | |
| var card = createCard(clip, idx); | |
| card.classList.add('visible'); | |
| cardWrap.appendChild(card); | |
| // Load preview media immediately | |
| var lazyMedia = card.querySelector('img[data-src], video[data-src]'); | |
| if (lazyMedia && lazyMedia.dataset.src) { | |
| lazyMedia.src = lazyMedia.dataset.src; | |
| lazyMedia.removeAttribute('data-src'); | |
| } | |
| } | |
| prevBtn.addEventListener('click', function () { | |
| if (carouselIndex > 0) showCard(carouselIndex - 1); | |
| }); | |
| nextBtn.addEventListener('click', function () { | |
| if (carouselIndex < filteredClips.length - 1) showCard(carouselIndex + 1); | |
| }); | |
| // Swipe support | |
| var touchStartX = 0; | |
| wrap.addEventListener('touchstart', function (e) { | |
| touchStartX = e.touches[0].clientX; | |
| }, { passive: true }); | |
| wrap.addEventListener('touchend', function (e) { | |
| var dx = e.changedTouches[0].clientX - touchStartX; | |
| if (Math.abs(dx) > 50) { | |
| if (dx < 0 && carouselIndex < filteredClips.length - 1) showCard(carouselIndex + 1); | |
| if (dx > 0 && carouselIndex > 0) showCard(carouselIndex - 1); | |
| } | |
| }, { passive: true }); | |
| showCard(0); | |
| } | |
| // ------------------------------------ | |
| // Card creation | |
| // ------------------------------------ | |
| function createCard(clip, index) { | |
| const card = document.createElement('div'); | |
| card.className = 'clip-card'; | |
| card.dataset.clipId = clip.id; | |
| var viewerUrl = 'viewer.html?clip=' + encodeURIComponent(clip.id) + | |
| '&category=' + encodeURIComponent(clip.category); | |
| // Get preview media for cards — use uploaded policy MP4 renders from Space | |
| var stageData = clip.stages.policy || clip.stages.training || clip.stages.retarget || clip.stages.capture; | |
| var mediaSrc = stageData ? cardMediaUrl(stageData, clip) : null; | |
| var isVideo = mediaSrc && mediaSrc.endsWith('.mp4'); | |
| var mediaHtml; | |
| if (mediaSrc) { | |
| mediaHtml = isVideo | |
| ? '<video data-src="' + mediaSrc + '" autoplay muted loop playsinline preload="none" class="lazy-video"></video>' | |
| : '<img data-src="' + mediaSrc + '" alt="' + escHtml(clip.name) + '" loading="lazy">'; | |
| } else { | |
| mediaHtml = '<div class="card-placeholder">Not available</div>'; | |
| } | |
| card.innerHTML = | |
| '<div class="card-media">' + | |
| mediaHtml + | |
| '<div class="card-duration">' + formatDuration(clip.duration) + '</div>' + | |
| (clip.has_policy ? '<div class="card-badge">TRAINED</div>' : '') + | |
| '</div>' + | |
| '<div class="card-info">' + | |
| '<div class="card-title">' + escHtml(clip.name) + '</div>' + | |
| '<div class="card-meta">' + | |
| '<span class="card-category">' + clip.category + '</span>' + | |
| '<span class="card-separator"></span>' + | |
| '<span>' + clip.performer + '</span>' + | |
| '</div>' + | |
| '<div class="card-actions">' + | |
| (clip.has_policy | |
| ? '<a class="card-view-btn" href="' + viewerUrl + '" title="Run policy in browser">' + | |
| '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>' + | |
| 'View' + | |
| '</a>' | |
| : '') + | |
| '</div>' + | |
| '</div>'; | |
| // Media click → open modal | |
| card.querySelector('.card-media').addEventListener('click', function () { | |
| openModal(clip); | |
| }); | |
| return card; | |
| } | |
| // ------------------------------------ | |
| // Lazy loading | |
| // ------------------------------------ | |
| var lazyObserver = null; | |
| function observeLazyImages() { | |
| if (!lazyObserver) { | |
| lazyObserver = new IntersectionObserver(function (entries) { | |
| entries.forEach(function (entry) { | |
| if (entry.isIntersecting) { | |
| var el = entry.target; | |
| if (el.dataset.src) { | |
| el.src = el.dataset.src; | |
| } | |
| lazyObserver.unobserve(el); | |
| } | |
| }); | |
| }, { rootMargin: '300px' }); | |
| } | |
| var lazyMedia = grid.querySelectorAll('img[data-src], video[data-src]'); | |
| for (var i = 0; i < lazyMedia.length; i++) { | |
| lazyObserver.observe(lazyMedia[i]); | |
| } | |
| } | |
| // ------------------------------------ | |
| // Card reveal on scroll | |
| // ------------------------------------ | |
| var revealObserver = null; | |
| function observeCardReveal() { | |
| if (!revealObserver) { | |
| revealObserver = new IntersectionObserver(function (entries) { | |
| entries.forEach(function (entry) { | |
| if (entry.isIntersecting) { | |
| entry.target.classList.add('visible'); | |
| revealObserver.unobserve(entry.target); | |
| } | |
| }); | |
| }, { threshold: 0.05, rootMargin: '40px' }); | |
| } | |
| var cards = grid.querySelectorAll('.clip-card:not(.visible)'); | |
| for (var i = 0; i < cards.length; i++) { | |
| // Stagger the transition delay | |
| cards[i].style.transitionDelay = (i * 0.04) + 's'; | |
| revealObserver.observe(cards[i]); | |
| } | |
| } | |
| // ------------------------------------ | |
| // Category buttons | |
| // ------------------------------------ | |
| function setupCategoryButtons() { | |
| var btns = document.querySelectorAll('.category-btn'); | |
| for (var i = 0; i < btns.length; i++) { | |
| btns[i].addEventListener('click', function () { | |
| setActiveBtn('.category-btn', this); | |
| activeCategory = this.dataset.category; | |
| filterAndRender(); | |
| }); | |
| } | |
| } | |
| function setActiveBtn(selector, activeEl) { | |
| var btns = document.querySelectorAll(selector); | |
| for (var i = 0; i < btns.length; i++) { | |
| btns[i].classList.toggle('active', btns[i] === activeEl); | |
| } | |
| } | |
| // ------------------------------------ | |
| // Modal | |
| // ------------------------------------ | |
| function setupModal() { | |
| var closeBtn = modal.querySelector('.modal-close'); | |
| if (closeBtn) { | |
| closeBtn.addEventListener('click', closeModal); | |
| } | |
| modal.addEventListener('click', function (e) { | |
| if (e.target === modal) closeModal(); | |
| }); | |
| document.addEventListener('keydown', function (e) { | |
| if (e.key === 'Escape') closeModal(); | |
| }); | |
| } | |
| function openModal(clip) { | |
| var stages = ['capture', 'retarget', 'training', 'policy']; | |
| modal.querySelector('.modal-title').textContent = clip.name; | |
| modal.querySelector('.modal-meta').textContent = | |
| clip.category + ' · ' + clip.performer + ' · ' + | |
| formatDuration(clip.duration) + ' · ' + | |
| clip.frames + ' frames @ ' + clip.fps + ' fps'; | |
| var modalGrid = modal.querySelector('.modal-grid'); | |
| modalGrid.innerHTML = stages.map(function (s) { | |
| var data = clip.stages[s]; | |
| var unavail = !data ? ' unavailable' : ''; | |
| var label = '<div class="modal-stage-label">' + s.toUpperCase() + '</div>'; | |
| var useViewer = s === 'policy' && clip.has_onnx; | |
| if (useViewer) { | |
| var iframeSrc = 'viewer.html?clip=' + encodeURIComponent(clip.id) + | |
| '&category=' + encodeURIComponent(clip.category) + '&embed=1'; | |
| return '<div class="modal-stage">' + | |
| label + | |
| '<iframe src="' + iframeSrc + '" style="width:100%;height:100%;border:none;min-height:300px;" allowtransparency="true"></iframe>' + | |
| '</div>'; | |
| } | |
| if (data) { | |
| var src = mediaUrl(data); | |
| var isVid = src && src.endsWith('.mp4'); | |
| var mediaTag = isVid | |
| ? '<video src="' + src + '" autoplay muted loop playsinline></video>' | |
| : '<img src="' + src + '" alt="' + escHtml(clip.name) + ' ' + s + '" loading="lazy">'; | |
| return '<div class="modal-stage' + unavail + '">' + | |
| label + mediaTag + | |
| '</div>'; | |
| } | |
| return '<div class="modal-stage' + unavail + '">' + | |
| label + | |
| '<div class="modal-placeholder">Not yet available</div>' + | |
| '</div>'; | |
| }).join(''); | |
| modal.classList.add('open'); | |
| document.body.style.overflow = 'hidden'; | |
| } | |
| function closeModal() { | |
| modal.classList.remove('open'); | |
| document.body.style.overflow = ''; | |
| // Pause modal videos to stop background playback | |
| var videos = modal.querySelectorAll('video'); | |
| for (var i = 0; i < videos.length; i++) { | |
| videos[i].pause(); | |
| } | |
| } | |
| // ------------------------------------ | |
| // Scroll reveal for sections | |
| // ------------------------------------ | |
| function setupScrollReveal() { | |
| var sections = document.querySelectorAll('.reveal'); | |
| if (!sections.length) return; | |
| var observer = new IntersectionObserver(function (entries) { | |
| entries.forEach(function (entry) { | |
| if (entry.isIntersecting) { | |
| entry.target.classList.add('revealed'); | |
| observer.unobserve(entry.target); | |
| } | |
| }); | |
| }, { threshold: 0.1 }); | |
| for (var i = 0; i < sections.length; i++) { | |
| observer.observe(sections[i]); | |
| } | |
| } | |
| // ------------------------------------ | |
| // Utils | |
| // ------------------------------------ | |
| function mediaUrl(stageData) { | |
| var file = stageData.mp4 || stageData.gif; | |
| if (!file) return null; | |
| return baseUrl ? baseUrl + '/' + file : file; | |
| } | |
| function cardMediaUrl(stageData, clip) { | |
| // Use Space-hosted policy renders (uploaded by render_all_policies.js) | |
| if (clip && clip.has_policy) { | |
| return 'media/' + clip.category + '/' + clip.id + '/policy/' + clip.id + '_policy.mp4'; | |
| } | |
| var file = stageData.mp4 || stageData.gif; | |
| if (!file) return null; | |
| return baseUrl ? baseUrl + '/' + file : file; | |
| } | |
| function formatDuration(seconds) { | |
| var m = Math.floor(seconds / 60); | |
| var s = Math.round(seconds % 60); | |
| return m + ':' + (s < 10 ? '0' : '') + s; | |
| } | |
| function escHtml(str) { | |
| var div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| // ------------------------------------ | |
| // Citation copy | |
| // ------------------------------------ | |
| function initCitation() { | |
| const btn = document.querySelector('.citation-copy'); | |
| if (!btn) return; | |
| btn.addEventListener('click', () => { | |
| const code = document.querySelector('.citation-block code'); | |
| if (!code) return; | |
| navigator.clipboard.writeText(code.textContent.trim()).then(() => { | |
| btn.classList.add('copied'); | |
| btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; | |
| setTimeout(() => { | |
| btn.classList.remove('copied'); | |
| btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; | |
| }, 2000); | |
| }); | |
| }); | |
| } | |
| // ------------------------------------ | |
| // Boot | |
| // ------------------------------------ | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => { init(); initCitation(); }); | |
| } else { | |
| init(); | |
| initCitation(); | |
| } | |
| })(); | |