| |
| 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 => |
| `<div class="gauge"><div class="val">${c.val}</div><div class="lbl">${c.lbl}</div><div class="sub">${c.sub}</div></div>`).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 ? `<span style="display:inline-block;width:9px;height:9px;border:1px solid #555;background:${k};margin-right:5px"></span>` : ''; |
| return `<div class="bar-row"><div class="k"><span>${sw}${k}</span><span>${v}</span></div><div class="bar"><span style="width:${100 * v / max}%"></span></div></div>`; |
| }).join(''); |
| return `<div class="bar-title">${title}</div>${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 = `<svg class="gamut-svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">`; |
| 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 += `<path d="M${p(R - 6, a0)} L${p(R, a0)} A${R} ${R} 0 0 1 ${p(R, a1)} L${p(R - 6, a1)} A${R - 6} ${R - 6} 0 0 0 ${p(R - 6, a0)} Z" fill="hsl(${an},85%,55%)" opacity="0.45"/>`; |
| } |
| (s.gamut || []).forEach(rgb => { |
| const [h, sat] = rgb2hsv(...rgb), rad = sat * (R - 10), ang = (h - 90) * Math.PI / 180; |
| svg += `<circle cx="${cx + rad * Math.cos(ang)}" cy="${cy + rad * Math.sin(ang)}" r="5" fill="${css(rgb)}" stroke="#fff" stroke-width="0.6"/>`; |
| }); |
| svg += `</svg>`; |
| el('gamut').innerHTML = svg; |
| } |
|
|
| function renderRecent(s) { |
| if (!s.recentCrosses.length) { el('recent').innerHTML = `<div class="empty-note">No crosses yet. Breed two specimens in Play Mode.</div>`; return; } |
| el('recent').innerHTML = s.recentCrosses.map(r => ` |
| <div class="cross"> |
| <span class="sw" style="background:${css(r.budColor)}"></span> |
| <div class="meta"><b>${r.name}</b> · F${r.generation} · THC ${r.THC}<div class="ped">${r.pedigree}</div></div> |
| </div>`).join(''); |
| } |
|
|
| function renderTray() { |
| const host = el('tray'); |
| if (!summary) return; |
| const byId = {}; |
| summary.recentCrosses.forEach(r => byId[r.id] = r); |
| |
| if (!selection.length) { host.innerHTML = `<div class="empty-note">No specimens selected. Pick in Play or Lab; they ride along here.</div>`; return; } |
| host.innerHTML = selection.map(id => { |
| const r = byId[id]; |
| const sw = r ? `<span class="sw" style="background:${css(r.budColor)}"></span>` : ''; |
| return `<span class="tray-chip">${sw}${r ? r.name : id.slice(0, 8)}</span>`; |
| }).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 = `<div class="empty-note">K-os not connected. Set KEY_OS_URL to stream signals here.</div>`; return; } |
| if (!sig.signals.length) { host.innerHTML = `<div class="empty-note">Bus online · no active signals.</div>`; return; } |
| host.innerHTML = sig.signals.map(g => ` |
| <div class="sig ${g.severity || 'info'}"><span class="lane">${g.lane}</span> ${g.title}<div class="ped">${g.message || ''}</div></div>`).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(); |
|
|