Spaces:
Running
Running
| import { loadData, filterResults, selectBestResults, expandCpuRows, attachCpuBaselineFromCpuRecords, mergeDepthPairs } from './data.js'; | |
| import { initFilters, populateQuantOptions, getFilters, resetFilters } from './filters.js'; | |
| import { renderDecodeChart, renderPrefillChart, renderSizeChart, renderMachineChart, renderCpuGpuChart, renderSpeedupChart } from './charts.js'; | |
| import { renderResultsTable, renderErrorTable, renderMachineInfo, renderCpuGpuTable } from './tables.js'; | |
| let appData = null; | |
| async function init() { | |
| try { | |
| appData = await loadData(); | |
| } catch (e) { | |
| const loading = document.getElementById('loading'); | |
| loading.className = 'loading-state'; | |
| loading.innerHTML = ` | |
| <div class="loading-content"> | |
| <p class="loading-error">Failed to load data</p> | |
| <p class="loading-hint">Run: <code>node scripts/build-site.js</code></p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Hide loading, show dashboard with entrance animation | |
| const loading = document.getElementById('loading'); | |
| const dashboard = document.getElementById('dashboard'); | |
| loading.style.display = 'none'; | |
| dashboard.style.display = ''; | |
| requestAnimationFrame(() => dashboard.classList.add('animate-in')); | |
| // Populate quant options from actual data | |
| populateQuantOptions(appData.results); | |
| // Surface the dataset's last-updated time so users know data freshness. | |
| renderHeroMeta(appData); | |
| // Init filter dropdowns | |
| initFilters(appData.meta, () => render()); | |
| // Wire theme toggle | |
| document.getElementById('theme-toggle')?.addEventListener('click', () => { | |
| const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; | |
| document.documentElement.setAttribute('data-theme', next); | |
| localStorage.setItem('theme', next); | |
| if (appData) render(); | |
| }); | |
| // Wire reset button | |
| const resetBtn = document.getElementById('filter-reset'); | |
| if (resetBtn) { | |
| resetBtn.addEventListener('click', () => { | |
| resetFilters(); | |
| render(); | |
| }); | |
| } | |
| // Wire metric selector for CPU vs GPU section | |
| const metricSelect = document.getElementById('cpu-gpu-metric'); | |
| if (metricSelect) { | |
| metricSelect.addEventListener('change', () => render()); | |
| } | |
| // Init section navigation | |
| initSectionNav(); | |
| // Initial render | |
| render(); | |
| } | |
| function render() { | |
| // Sync Chart.js defaults with current theme | |
| const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| Chart.defaults.color = isDark ? '#a1a1aa' : '#71717a'; | |
| Chart.defaults.plugins.tooltip.backgroundColor = isDark ? 'rgba(15,15,18,0.95)' : 'rgba(255,255,255,0.95)'; | |
| Chart.defaults.plugins.tooltip.borderColor = isDark ? '#27272a' : '#e4e4e7'; | |
| Chart.defaults.plugins.tooltip.titleColor = isDark ? '#e4e4e7' : '#09090b'; | |
| Chart.defaults.plugins.tooltip.bodyColor = isDark ? '#a1a1aa' : '#71717a'; | |
| const filters = getFilters(); | |
| // Filter, attach CPU baseline values (folds CLI-flow CPU records onto | |
| // their GPU sibling so both submission paths produce one row per cell), | |
| // fold the (d=0, d=N) study pair into a single GPU row carrying both | |
| // depths, collapse to one canonical row per (machine, browser, model, | |
| // variant, backend), then drop the now-redundant CPU rows. CPU numbers | |
| // stay visible via the cpu_baseline_* columns on each GPU row. | |
| const filtered = selectBestResults( | |
| mergeDepthPairs( | |
| attachCpuBaselineFromCpuRecords(filterResults(appData.results, filters)), | |
| ), | |
| ).filter(r => r.nGpuLayers !== 0); | |
| // Summary cards — counts tween from previous value to new on filter changes | |
| // and from 0 on first paint (since `data-value` defaults to "0"). | |
| const passed = filtered.filter(r => r.status === 'done'); | |
| animateCount(document.getElementById('stat-machines'), appData.meta.machines.length, { decimals: 0 }); | |
| animateCount(document.getElementById('stat-benchmarks'), filtered.length, { decimals: 0 }); | |
| const passRate = filtered.length > 0 ? (passed.length / filtered.length) * 100 : 0; | |
| animateCount(document.getElementById('stat-pass-rate'), passRate, { decimals: 0 }); | |
| const decodeVals = passed.map(r => r.decode_tok_s).filter(v => v != null); | |
| const bestDecode = decodeVals.length ? Math.max(...decodeVals) : 0; | |
| animateCount(document.getElementById('stat-best-decode'), bestDecode, { decimals: 1 }); | |
| const sizes = passed.map(r => r.sizeMB).filter(v => v != null); | |
| const largest = sizes.length ? Math.max(...sizes) : 0; | |
| animateCount(document.getElementById('stat-largest'), largest, { decimals: 0 }); | |
| // Results count | |
| const countEl = document.getElementById('results-count'); | |
| if (countEl) { | |
| const total = appData.results.length; | |
| countEl.textContent = filtered.length === total | |
| ? `${total} total` | |
| : `${filtered.length} of ${total}`; | |
| } | |
| // Reset button — only present when at least one filter is active. Hiding | |
| // (rather than disabling) removes a permanent ghost button from the bar | |
| // and makes the appearance signal "you can undo your filter." | |
| const resetBtn = document.getElementById('filter-reset'); | |
| if (resetBtn) { | |
| const activeCount = (filters.machine !== 'all' ? 1 : 0) + (filters.browser !== 'all' ? 1 : 0) + | |
| (filters.model !== 'all' ? 1 : 0) + (filters.backend !== 'all' ? 1 : 0) + | |
| (filters.status !== 'all' ? 1 : 0) + (filters.quants.size > 0 ? 1 : 0); | |
| resetBtn.disabled = activeCount === 0; | |
| resetBtn.hidden = activeCount === 0; | |
| const label = resetBtn.querySelector('.filter-reset-label') || resetBtn; | |
| if (label !== resetBtn) { | |
| label.textContent = activeCount ? `Reset (${activeCount})` : 'Reset'; | |
| } | |
| } | |
| // Tables | |
| renderResultsTable(filtered); | |
| renderErrorTable(filtered); | |
| renderMachineInfo(appData.meta.machines); | |
| // Charts | |
| renderDecodeChart(filtered); | |
| renderPrefillChart(filtered); | |
| renderSizeChart(filtered); | |
| renderMachineChart(filtered, appData.meta.machines); | |
| // CPU vs GPU comparison | |
| const metric = document.getElementById('cpu-gpu-metric')?.value || 'decode_tok_s'; | |
| renderCpuGpuSection(filtered, metric); | |
| } | |
| /* Consolidate the 3-part CPU-vs-GPU block (two charts + table). When there | |
| is no CPU baseline or no overlapping GPU data, render a single inline | |
| empty state and hide the charts+table so the user doesn't see the same | |
| message repeated three times. */ | |
| function renderCpuGpuSection(filtered, metric) { | |
| const chartsGrid = document.querySelector('#performance-section .charts-grid:nth-of-type(2)'); | |
| const table = document.getElementById('cpu-gpu-table'); | |
| const passed = filtered.filter(r => r.status === 'done'); | |
| // Same expansion the chart/table renderers do — see expandCpuRows(). | |
| const cpuResults = expandCpuRows(passed); | |
| const gpuResults = passed.filter(r => r.nGpuLayers !== 0); | |
| if (!chartsGrid || !table) { | |
| renderCpuGpuChart(filtered, metric); | |
| renderSpeedupChart(filtered, metric); | |
| renderCpuGpuTable(filtered); | |
| return; | |
| } | |
| if (cpuResults.length === 0 || gpuResults.length === 0) { | |
| chartsGrid.hidden = true; | |
| const reason = cpuResults.length === 0 | |
| ? 'No CPU baseline in the current filter. Select "All Backends" or enable CPU baselines when benchmarking with <code>--consistency</code>.' | |
| : 'No WebGPU runs in the current filter. Adjust the Backend filter to include WebGPU.'; | |
| table.innerHTML = `<div class="empty-state"><p>${reason}</p></div>`; | |
| return; | |
| } | |
| chartsGrid.hidden = false; | |
| renderCpuGpuChart(filtered, metric); | |
| renderSpeedupChart(filtered, metric); | |
| renderCpuGpuTable(filtered); | |
| } | |
| function renderHeroMeta(data) { | |
| const el = document.getElementById('hero-meta'); | |
| const liveEl = document.getElementById('hero-live'); | |
| const liveText = document.getElementById('hero-live-text'); | |
| const generated = data?.meta?.generatedAt; | |
| const machineCount = data?.meta?.machines?.length || 0; | |
| const resultCount = data?.results?.length || 0; | |
| if (el) { | |
| const parts = []; | |
| if (machineCount > 0) parts.push(`${machineCount} machine${machineCount === 1 ? '' : 's'}`); | |
| if (resultCount > 0) parts.push(`${resultCount} benchmark${resultCount === 1 ? '' : 's'}`); | |
| if (generated) parts.push(`updated ${formatRelativeTime(new Date(generated))}`); | |
| if (parts.length > 0) { | |
| el.textContent = parts.join(' · '); | |
| el.hidden = false; | |
| if (generated) el.title = new Date(generated).toLocaleString(); | |
| } | |
| } | |
| if (liveEl && liveText && generated) { | |
| liveText.textContent = `Live · ${formatRelativeTime(new Date(generated))}`; | |
| liveEl.hidden = false; | |
| } | |
| // Hero stat: top decode tok/s with machine + model context. Uses the | |
| // canonical set (best per cell) so a noisy 1-iteration outlier can't | |
| // hijack the headline number. Depth-merge first so a Study cell counts | |
| // once at its d=N number, not twice. | |
| const canonical = selectBestResults(mergeDepthPairs(data?.results || [])); | |
| const passed = canonical.filter(r => r.status === 'done' && r.decode_tok_s != null); | |
| const heroStatEl = document.getElementById('hero-stat'); | |
| const heroNumEl = document.getElementById('hero-top-decode'); | |
| const heroMetaEl = document.getElementById('hero-top-meta'); | |
| if (heroStatEl && heroNumEl && heroMetaEl && passed.length > 0) { | |
| const top = passed.reduce((a, b) => (a.decode_tok_s > b.decode_tok_s ? a : b)); | |
| heroStatEl.hidden = false; | |
| heroMetaEl.textContent = `${top.machineSlug || top.machine || '—'} · ${top.model || ''} ${top.variant || ''}`.trim(); | |
| animateCount(heroNumEl, top.decode_tok_s, { decimals: 1, duration: 800 }); | |
| } | |
| } | |
| /* Tween numeric content from 0 to a target. CSS-only via @property would | |
| need server-side @property registration to work in older Safari; keep | |
| this 12-line JS tween for predictability. */ | |
| function animateCount(el, target, { decimals = 0, duration = 600 } = {}) { | |
| if (!el) return; | |
| const start = parseFloat(el.dataset.value || '0') || 0; | |
| const end = Number(target) || 0; | |
| if (start === end) { | |
| el.textContent = end.toFixed(decimals); | |
| return; | |
| } | |
| const startTime = performance.now(); | |
| const ease = (t) => 1 - Math.pow(1 - t, 3); | |
| function step(now) { | |
| const t = Math.min(1, (now - startTime) / duration); | |
| const v = start + (end - start) * ease(t); | |
| el.textContent = v.toFixed(decimals); | |
| if (t < 1) requestAnimationFrame(step); | |
| else el.dataset.value = String(end); | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| function formatRelativeTime(date) { | |
| const now = Date.now(); | |
| const diff = Math.max(0, now - date.getTime()); | |
| const min = 60_000, hr = 60 * min, day = 24 * hr; | |
| if (diff < min) return 'just now'; | |
| if (diff < hr) return `${Math.floor(diff / min)} min ago`; | |
| if (diff < day) return `${Math.floor(diff / hr)} h ago`; | |
| const days = Math.floor(diff / day); | |
| if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`; | |
| return date.toISOString().slice(0, 10); | |
| } | |
| function initSectionNav() { | |
| const nav = document.getElementById('section-nav'); | |
| if (!nav) return; | |
| const track = nav.querySelector('.section-nav-track'); | |
| const buttons = nav.querySelectorAll('.section-nav-item'); | |
| const sections = []; | |
| // Prefer the sticky wrapper height so the jumped-to section isn't | |
| // obscured by the sticky head. | |
| const stickyHead = document.querySelector('.sticky-head') || nav; | |
| buttons.forEach(btn => { | |
| const sectionId = btn.dataset.section; | |
| const section = document.getElementById(sectionId); | |
| if (section) sections.push({ btn, section }); | |
| btn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| if (section) { | |
| const offset = stickyHead.offsetHeight + 8; | |
| const top = section.getBoundingClientRect().top + window.scrollY - offset; | |
| window.scrollTo({ top, behavior: 'smooth' }); | |
| } | |
| }); | |
| }); | |
| // Drive the sliding indicator from the active button's geometry. Track | |
| // is the positioned ancestor; offsetLeft/offsetWidth are relative to it. | |
| const moveIndicator = (btn) => { | |
| if (!track || !btn) return; | |
| track.style.setProperty('--indicator-x', `${btn.offsetLeft}px`); | |
| track.style.setProperty('--indicator-w', `${btn.offsetWidth}px`); | |
| }; | |
| // Scroll spy: instead of IntersectionObserver (which fires inconsistently | |
| // when multiple sections overlap the observer band), compute the | |
| // currently-active section on scroll by comparing each section's top to | |
| // the bottom of the sticky head. Cheaper and predictable. | |
| if (sections.length === 0) return; | |
| let ticking = false; | |
| const updateActive = () => { | |
| const anchor = stickyHead.offsetHeight + 16; | |
| let active = sections[0]; | |
| for (const entry of sections) { | |
| const top = entry.section.getBoundingClientRect().top; | |
| if (top - anchor <= 0) active = entry; | |
| else break; | |
| } | |
| buttons.forEach(b => b.classList.toggle('active', b === active.btn)); | |
| moveIndicator(active.btn); | |
| }; | |
| const onScroll = () => { | |
| if (ticking) return; | |
| ticking = true; | |
| requestAnimationFrame(() => { updateActive(); ticking = false; }); | |
| }; | |
| window.addEventListener('scroll', onScroll, { passive: true }); | |
| window.addEventListener('resize', onScroll); | |
| updateActive(); | |
| // Re-measure once fonts settle — Bricolage Grotesque shifts widths. | |
| document.fonts?.ready?.then(() => updateActive()).catch(() => {}); | |
| } | |
| init(); | |