// The Deck — shared-state equilibrium dashboard bridging Play and Lab. let summary = null, selection = []; function rgb2hsv(r, g, b) { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; let h = 0; if (d) { if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h *= 60; if (h < 0) h += 360; } return [h, mx === 0 ? 0 : d / mx, mx]; } const css = (rgb) => `rgb(${rgb.join(',')})`; const el = (id) => document.getElementById(id); async function load() { const [s, sel, sig] = await Promise.all([ fetch('/api/deck/summary?t=' + Date.now()).then(r => r.json()), fetch('/api/selection').then(r => r.json()), fetch('/api/deck/signals').then(r => r.json()), ]); summary = s; selection = sel.ids || []; renderGauges(s); renderPortals(s); renderBreakdowns(s); renderGamut(s); renderRecent(s); renderTray(); renderSignals(sig); } function renderGauges(s) { const a = s.traitAverages; const cells = [ { val: s.accessions, lbl: 'Accessions', sub: `F0 → F${s.maxGeneration}` }, { val: s.breedableCount, lbl: 'Breedable', sub: `${s.growingCount} growing` }, { val: a.THC, lbl: 'Avg THC', sub: `CBD ${a.CBD}%` }, { val: a.Yield, lbl: 'Avg Yield', sub: `${a.GrowTime}d grow` }, { val: a.Stability, lbl: 'Avg Stability', sub: `${Object.keys(s.byBudFamily).length} color families` }, ]; el('gauges').innerHTML = cells.map(c => `
${c.val}
${c.lbl}
${c.sub}
`).join(''); } function renderPortals(s) { el('play-sub').textContent = `${s.breedableCount} breedable · ${s.growingCount} growing`; el('lab-sub').textContent = `${s.accessions} accessions · ${Object.keys(s.byGeneration).length} generations`; } function bars(title, obj, swatch) { const max = Math.max(1, ...Object.values(obj)); const rows = Object.entries(obj).map(([k, v]) => { const sw = swatch ? `` : ''; return `
${sw}${k}${v}
`; }).join(''); return `
${title}
${rows}`; } function renderBreakdowns(s) { el('breakdowns').innerHTML = bars('Type', s.byType) + bars('Generation', s.byGeneration) + bars('Bud Family', s.byBudFamily, true); } function renderGamut(s) { const S = 240, R = S / 2 - 14, cx = S / 2, cy = S / 2; const NS = 'http://www.w3.org/2000/svg'; let svg = ``; for (let an = 0; an < 360; an += 8) { const a0 = (an - 90) * Math.PI / 180, a1 = (an + 8 - 90) * Math.PI / 180; const p = (r, a) => `${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`; svg += ``; } (s.gamut || []).forEach(rgb => { const [h, sat] = rgb2hsv(...rgb), rad = sat * (R - 10), ang = (h - 90) * Math.PI / 180; svg += ``; }); svg += ``; el('gamut').innerHTML = svg; } function renderRecent(s) { if (!s.recentCrosses.length) { el('recent').innerHTML = `
No crosses yet. Breed two specimens in Play Mode.
`; return; } el('recent').innerHTML = s.recentCrosses.map(r => `
${r.name} · F${r.generation} · THC ${r.THC}
${r.pedigree}
`).join(''); } function renderTray() { const host = el('tray'); if (!summary) return; const byId = {}; summary.recentCrosses.forEach(r => byId[r.id] = r); // fall back: we only have colors for recent; show ids/names where known if (!selection.length) { host.innerHTML = `
No specimens selected. Pick in Play or Lab; they ride along here.
`; return; } host.innerHTML = selection.map(id => { const r = byId[id]; const sw = r ? `` : ''; return `${sw}${r ? r.name : id.slice(0, 8)}`; }).join(''); } function renderSignals(sig) { const chip = el('kos-chip'); if (sig.available) { chip.textContent = `Observer Bus: online (${sig.count || 0})`; chip.classList.add('online'); } else { chip.textContent = 'Observer Bus: offline'; chip.classList.remove('online'); } const host = el('signals'); if (!sig.available) { host.innerHTML = `
K-os not connected. Set KEY_OS_URL to stream signals here.
`; return; } if (!sig.signals.length) { host.innerHTML = `
Bus online · no active signals.
`; return; } host.innerHTML = sig.signals.map(g => `
${g.lane} ${g.title}
${g.message || ''}
`).join(''); } async function saveSelection(ids) { selection = ids; await fetch('/api/selection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); renderTray(); } el('btn-refresh').onclick = load; el('btn-clear-sel').onclick = () => saveSelection([]); el('btn-to-play').onclick = () => { location.href = '/play'; }; el('btn-to-lab').onclick = () => { location.href = '/lab'; }; load();