File size: 5,978 Bytes
ae853c1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | // 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 =>
`<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);
// fall back: we only have colors for recent; show ids/names where known
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();
|