/* NanoDex — shared frontend helpers */ const NX = { fmtTokens(n){ n = Number(n||0); if(n>=1e9) return (n/1e9).toFixed(2)+'B'; if(n>=1e6) return (n/1e6).toFixed(n>=1e7?0:1).replace(/\.0$/,'')+'M'; if(n>=1e3) return Math.round(n/1e3)+'k'; return String(n); }, fmtParams(n){ n = Number(n||0); return n>=1e6 ? (n/1e6).toFixed(2)+'M' : Math.round(n/1e3)+'k'; }, fmtTime(s){ s = Number(s||0); if(!isFinite(s)||s<0) return '—'; if(s<90) return Math.round(s)+'s'; if(s<5400) return Math.round(s/60)+' min'; return (s/3600).toFixed(1)+' h'; }, ago(ts){ if(!ts) return '—'; const d = Date.now()/1000 - ts; if(d<60) return 'just now'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; }, esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }, chip(status, extra){ const label = {queued:'queued', running:'training', done:'completed', failed:'failed', cancelled:'cancelled'}[status] || status; return `${label}${extra?' · '+NX.esc(extra):''}`; }, async get(url){ const r = await fetch(url, {headers:{'Accept':'application/json'}}); if(!r.ok) throw new Error((await r.json().catch(()=>({}))).error || r.statusText); return r.json(); }, async post(url, body){ const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})}); const j = await r.json().catch(()=>({})); if(!r.ok) throw new Error(j.error || r.statusText); return j; }, toast(msg, kind){ let t = document.querySelector('.toast'); if(!t){ t = document.createElement('div'); t.className='toast'; document.body.appendChild(t); } t.className = 'toast ' + (kind||''); t.innerHTML = msg; requestAnimationFrame(()=> t.classList.add('on')); clearTimeout(t._h); t._h = setTimeout(()=> t.classList.remove('on'), 5200); }, runRow(j){ const pct = (j.progress*100).toFixed(1); const bits = []; if(j.status==='running'){ bits.push(`${pct}% of ${NX.fmtTokens(j.target_tokens)}`); if(j.loss) bits.push(`loss ${j.loss.toFixed(4)}`); bits.push(`${Math.round(j.tok_per_s/1e3)}k tok/s`); if(j.eta_s) bits.push(`${NX.fmtTime(j.eta_s)} left`); } else if(j.status==='queued'){ bits.push(`position #${j.queue_position||'?'}`); bits.push(`${NX.fmtTokens(j.target_tokens)} tokens queued`); } else if(j.status==='done'){ bits.push(`${NX.fmtTokens(j.tokens_seen)} tokens`); if(j.loss) bits.push(`final loss ${j.loss.toFixed(4)}`); bits.push(`${j.pushed_repo ? 'published' : 'not published'}`); } else { bits.push(`${NX.esc(j.error||'—')}`); } const showBar = j.status==='running'; return `

${NX.esc(j.name)}

${NX.chip(j.status)}
${NX.esc(j.tier_label)} ${Number(j.n_params).toLocaleString()} params ${bits.join('')} ${NX.ago(j.created_at)}
${showBar ? `
` : ''}
`; }, /* Minimal dependency-free loss chart. The viewBox is set to the element's real pixel size so that 1 user unit == 1 CSS pixel: a fixed 760-wide viewBox squeezed into a 280px phone would render the axis labels at ~4px. */ lossChart(el, pts){ if(!el) return; el._pts = pts; const rect = el.getBoundingClientRect(); const W = Math.max(280, Math.round(rect.width) || 760); const H = Math.max(170, Math.round(rect.height) || 280); const narrow = W < 460; const PL = narrow ? 40 : 54, PR = narrow ? 8 : 14; const PT = 16, PB = narrow ? 28 : 34; const TICKS = narrow ? 3 : 4; el.setAttribute('viewBox', `0 0 ${W} ${H}`); if(!pts || pts.length < 2){ el.innerHTML = `waiting for the first metrics…`; return; } const xs = pts.map(p=>p.tokens), ys = pts.map(p=>p.loss); const x0=Math.min(...xs), x1=Math.max(...xs); let y0=Math.min(...ys), y1=Math.max(...ys); const padY=(y1-y0)*0.12 || 0.4; y0-=padY; y1+=padY; const sx = v => PL + (v-x0)/((x1-x0)||1)*(W-PL-PR); const sy = v => PT + (1-(v-y0)/((y1-y0)||1))*(H-PT-PB); const d = pts.map((p,i)=> (i?'L':'M')+sx(p.tokens).toFixed(1)+' '+sy(p.loss).toFixed(1)).join(' '); const area = d + ` L${sx(x1).toFixed(1)} ${H-PB} L${sx(x0).toFixed(1)} ${H-PB} Z`; let g=''; for(let i=0;i<=TICKS;i++){ const v = y0+(y1-y0)*i/TICKS, y = sy(v); g += ``; g += `${v.toFixed(2)}`; } for(let i=0;i<=TICKS;i++){ const v = x0+(x1-x0)*i/TICKS, x = sx(v); const anchor = i===0 ? 'start' : (i===TICKS ? 'end' : 'middle'); g += `${NX.fmtTokens(v)}`; } const last = pts[pts.length-1]; el.innerHTML = ` ${g} loss ${last.loss.toFixed(4)}`; }, poll(fn, ms){ let stop=false; const tick = async () => { if(stop) return; try { await fn(); } catch(e){ /* keep polling */ } if(!stop) setTimeout(tick, ms); }; tick(); return ()=>{ stop=true; }; } }; window.NX = NX; /* Charts use pixel-sized viewBoxes, so they must be redrawn when the box changes — rotating a phone, or opening the keyboard. */ let _rsT; addEventListener('resize', () => { clearTimeout(_rsT); _rsT = setTimeout(() => { document.querySelectorAll('svg.chart').forEach(el => { if(el._pts) NX.lossChart(el, el._pts); }); }, 160); });