weed-sim / static /lab.js
tostido's picture
WEED-SIM: evolutionary genetics sandbox with embedded Observer Bus
ae853c1
Raw
History Blame Contribute Delete
11.9 kB
// 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 = `<div class="facet-title">${label}</div>`;
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' ? `<span class="sw" style="background:${val}"></span>` : '';
chip.innerHTML = `${sw}${val} <span class="c">${n}</span>`;
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]) =>
`<th data-k="${k}">${l}${state.sort.key === k ? (state.sort.dir > 0 ? ' ▲' : ' ▼') : ''}</th>`).join('');
const body = sorted.map(r => `
<tr>
<td style="color:${css(r.budColor)}">${r.germplasmName}</td>
<td>${r.germplasmType}</td>
<td><span class="gen-pill">F${r.generation}</span></td>
<td>${r.THC}</td><td>${r.CBD}</td><td>${r.Yield}</td><td>${r.GrowTime}</td>
<td><span class="sw-cell" style="background:${css(r.budColor)}"></span> ${r.BudHue}°</td>
<td><span class="sw-cell" style="background:${css(r.leafColor)}"></span> ${r.LeafHue}°</td>
<td>${r.Stability}</td>
<td style="color:var(--text-muted)">${r.pedigree}</td>
</tr>`).join('');
document.getElementById('grid-wrap').innerHTML =
`<table class="germ"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
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 => `<option value="${r.germplasmDbId}">${r.germplasmName} (F${r.generation})</option>`).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 = `<div class="hist-label" style="margin-bottom:8px">${d.parents[0]} × ${d.parents[1]} · n=${d.n}</div>`;
['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 = `<div class="hist-label">${key} <span>μ <b>${s.mean}</b> · ${s.min}${s.max}</span></div>`;
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();