// Speciation Bench — germplasm analytics over the session catalog. const state = { variables: [], records: [], facetFilters: { germplasmType: new Set(), budFamily: new Set(), generation: new Set() }, pcBrush: {}, // key -> [lo, hi] in data units sort: { key: 'germplasmName', dir: 1 }, predictCloud: null, // bud rgb[] overlay on the wheel }; const FACET_GROUPS = [ ['germplasmType', 'Type'], ['budFamily', 'Bud Family'], ['generation', 'Generation'], ]; 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(',')})`; async function load() { const res = await fetch('/api/lab/germplasm?t=' + Date.now()); const data = await res.json(); state.variables = data.variables; state.records = data.records; document.getElementById('count-badge').textContent = `${data.count} accessions`; renderFacets(data.facets); renderPredictControls(); renderAll(); } function filtered() { return state.records.filter(r => { for (const [key] of FACET_GROUPS) { const set = state.facetFilters[key]; if (set.size && !set.has(String(r[key]))) return false; } for (const [key, ext] of Object.entries(state.pcBrush)) { if (r[key] < ext[0] || r[key] > ext[1]) return false; } return true; }); } function renderAll() { const rows = filtered(); renderGrid(rows); renderParallel(rows); renderWheel(rows); // reflect active state on facet chips document.querySelectorAll('.chip').forEach(ch => { ch.classList.toggle('active', state.facetFilters[ch.dataset.group]?.has(ch.dataset.value)); }); } /* ---------- Facets ---------- */ function renderFacets(facets) { const rail = document.getElementById('facet-rail'); rail.innerHTML = ''; FACET_GROUPS.forEach(([key, label]) => { const counts = facets[key] || {}; const grp = document.createElement('div'); grp.className = 'facet-group'; grp.innerHTML = `
${label}
`; Object.entries(counts).forEach(([val, n]) => { const chip = document.createElement('span'); chip.className = 'chip'; chip.dataset.group = key; chip.dataset.value = val; const sw = key === 'budFamily' ? `` : ''; chip.innerHTML = `${sw}${val} ${n}`; chip.onclick = () => { const set = state.facetFilters[key]; set.has(val) ? set.delete(val) : set.add(val); renderAll(); }; grp.appendChild(chip); }); rail.appendChild(grp); }); } /* ---------- Grid ---------- */ const COLS = [ ['germplasmName', 'Strain'], ['germplasmType', 'Type'], ['generation', 'Gen'], ['THC', 'THC'], ['CBD', 'CBD'], ['Yield', 'Yield'], ['GrowTime', 'Days'], ['BudHue', 'Bud'], ['LeafHue', 'Leaf'], ['Stability', 'Stab'], ['pedigree', 'Pedigree'], ]; function renderGrid(rows) { const sorted = [...rows].sort((a, b) => { const k = state.sort.key, va = a[k], vb = b[k]; return (va < vb ? -1 : va > vb ? 1 : 0) * state.sort.dir; }); const head = COLS.map(([k, l]) => `${l}${state.sort.key === k ? (state.sort.dir > 0 ? ' ▲' : ' ▼') : ''}`).join(''); const body = sorted.map(r => ` ${r.germplasmName} ${r.germplasmType} F${r.generation} ${r.THC}${r.CBD}${r.Yield}${r.GrowTime} ${r.BudHue}° ${r.LeafHue}° ${r.Stability} ${r.pedigree} `).join(''); document.getElementById('grid-wrap').innerHTML = `${head}${body}
`; document.querySelectorAll('table.germ th').forEach(th => { th.onclick = () => { const k = th.dataset.k; if (state.sort.key === k) state.sort.dir *= -1; else state.sort = { key: k, dir: 1 }; renderGrid(filtered()); }; }); } /* ---------- Parallel coordinates ---------- */ function renderParallel(rows) { const host = document.getElementById('parallel-coords'); host.innerHTML = ''; const dims = state.variables; const W = host.clientWidth || 700, H = host.clientHeight || 230; const m = { top: 22, right: 24, bottom: 18, left: 24 }; const svg = d3.select(host).append('svg').attr('width', W).attr('height', H); const x = d3.scalePoint().domain(dims.map(d => d.key)).range([m.left, W - m.right]); const y = {}; dims.forEach(d => { y[d.key] = d3.scaleLinear().domain([d.min, d.max]).range([H - m.bottom, m.top]); }); const line = d3.line(); const path = r => line(dims.map(d => [x(d.key), y[d.key](r[r === null ? 0 : d.key])])); const ids = new Set(rows.map(r => r.germplasmDbId)); svg.append('g').selectAll('path').data(state.records).join('path') .attr('class', r => 'pc-line' + (ids.has(r.germplasmDbId) ? '' : ' dim')) .attr('stroke', r => css(r.budColor)) .attr('d', r => line(dims.map(d => [x(d.key), y[d.key](r[d.key])]))); const axes = svg.selectAll('.pc-axis').data(dims).join('g') .attr('class', 'pc-axis').attr('transform', d => `translate(${x(d.key)},0)`); axes.each(function (d) { d3.select(this).call(d3.axisLeft(y[d.key]).ticks(4).tickFormat(d3.format('~s'))); }); axes.append('text').attr('class', 'pc-title').attr('y', m.top - 8) .attr('text-anchor', 'middle').text(d => d.label); // brushing per axis axes.append('g').attr('class', 'brush').each(function (d) { d3.select(this).call( d3.brushY() .extent([[-9, m.top], [9, H - m.bottom]]) .on('brush end', (ev) => { if (ev.selection) { const [hi, lo] = ev.selection.map(y[d.key].invert); state.pcBrush[d.key] = [lo, hi]; } else { delete state.pcBrush[d.key]; } const rs = filtered(); renderGrid(rs); renderWheel(rs); const idset = new Set(rs.map(r => r.germplasmDbId)); svg.selectAll('.pc-line').classed('dim', r => !idset.has(r.germplasmDbId)); }) ); }); } /* ---------- HSV color gamut wheel ---------- */ function renderWheel(rows) { const host = document.getElementById('color-wheel'); host.innerHTML = ''; const S = Math.min(host.clientWidth || 280, 280); const R = S / 2 - 16, cx = S / 2, cy = S / 2; const svg = d3.select(host).append('svg').attr('width', S).attr('height', S); // hue ring backdrop const ring = svg.append('g'); for (let a = 0; a < 360; a += 6) { ring.append('path') .attr('d', d3.arc()({ innerRadius: R - 6, outerRadius: R, startAngle: a * Math.PI / 180, endAngle: (a + 6) * Math.PI / 180 })) .attr('transform', `translate(${cx},${cy})`) .attr('fill', d3.hsl(a, 0.9, 0.5).toString()).attr('opacity', 0.5); } svg.append('circle').attr('cx', cx).attr('cy', cy).attr('r', R).attr('fill', 'none').attr('stroke', 'rgba(255,255,255,0.08)'); const ids = new Set(rows.map(r => r.germplasmDbId)); const place = (rgb) => { const [h, s] = rgb2hsv(...rgb), rad = s * (R - 10), ang = (h - 90) * Math.PI / 180; return [cx + rad * Math.cos(ang), cy + rad * Math.sin(ang)]; }; // predicted progeny cloud (faint) if (state.predictCloud) { svg.append('g').selectAll('circle').data(state.predictCloud).join('circle') .attr('cx', d => place(d)[0]).attr('cy', d => place(d)[1]).attr('r', 2) .attr('fill', d => css(d)).attr('opacity', 0.35); } svg.append('g').selectAll('circle').data(state.records).join('circle') .attr('cx', r => place(r.budColor)[0]).attr('cy', r => place(r.budColor)[1]) .attr('r', r => ids.has(r.germplasmDbId) ? 6 : 3) .attr('fill', r => css(r.budColor)) .attr('stroke', r => ids.has(r.germplasmDbId) ? '#fff' : 'none').attr('stroke-width', 1) .attr('opacity', r => ids.has(r.germplasmDbId) ? 1 : 0.25) .append('title').text(r => `${r.germplasmName} · bud ${r.BudHue}°`); } /* ---------- Cross predictor ---------- */ function renderPredictControls() { const opts = state.records.map(r => ``).join(''); document.getElementById('pred-a').innerHTML = opts; document.getElementById('pred-b').innerHTML = opts; if (state.records[1]) document.getElementById('pred-b').selectedIndex = 1; } async function runPredict() { const a = document.getElementById('pred-a').value, b = document.getElementById('pred-b').value; const res = await fetch('/api/lab/predict-cross', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parent1_id: a, parent2_id: b, n: 300 }), }); if (!res.ok) return; const d = await res.json(); state.predictCloud = d.budCloud; renderWheel(filtered()); const out = document.getElementById('predict-output'); out.innerHTML = `
${d.parents[0]} × ${d.parents[1]} · n=${d.n}
`; ['THC', 'CBD', 'Yield', 'GrowTime'].forEach(k => out.appendChild(histogram(k, d[k]))); } function histogram(key, s) { const wrap = document.createElement('div'); wrap.className = 'hist-row'; wrap.innerHTML = `
${key} μ ${s.mean} · ${s.min}–${s.max}
`; const W = 290, H = 42; const svg = d3.select(wrap).append('svg').attr('width', '100%').attr('viewBox', `0 0 ${W} ${H}`); const x = d3.scaleLinear().domain([s.min, s.max === s.min ? s.min + 1 : s.max]).range([0, W]); const bins = d3.bin().domain(x.domain()).thresholds(16)(s.values); const y = d3.scaleLinear().domain([0, d3.max(bins, b => b.length) || 1]).range([H, 2]); svg.selectAll('rect').data(bins).join('rect') .attr('x', b => x(b.x0) + 0.5).attr('width', b => Math.max(1, x(b.x1) - x(b.x0) - 1)) .attr('y', b => y(b.length)).attr('height', b => H - y(b.length)) .attr('fill', 'var(--accent-lime)').attr('opacity', 0.7); return wrap; } /* ---------- wiring ---------- */ document.getElementById('btn-refresh').onclick = load; document.getElementById('btn-predict').onclick = runPredict; document.getElementById('btn-clear-filters').onclick = () => { FACET_GROUPS.forEach(([k]) => state.facetFilters[k].clear()); state.pcBrush = {}; renderAll(); }; document.getElementById('btn-newick').onclick = async () => { const txt = await (await fetch('/api/lab/newick')).text(); const blob = new Blob([txt], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'weedsim_pedigree.nwk'; a.click(); }; window.addEventListener('resize', () => renderAll()); load();