| |
| const state = { |
| variables: [], |
| records: [], |
| facetFilters: { germplasmType: new Set(), budFamily: new Set(), generation: new Set() }, |
| pcBrush: {}, |
| sort: { key: 'germplasmName', dir: 1 }, |
| predictCloud: null, |
| }; |
|
|
| 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); |
| |
| document.querySelectorAll('.chip').forEach(ch => { |
| ch.classList.toggle('active', state.facetFilters[ch.dataset.group]?.has(ch.dataset.value)); |
| }); |
| } |
|
|
| |
| 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); |
| }); |
| } |
|
|
| |
| 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()); |
| }; |
| }); |
| } |
|
|
| |
| 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); |
|
|
| |
| 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)); |
| }) |
| ); |
| }); |
| } |
|
|
| |
| 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); |
| |
| 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)]; |
| }; |
| |
| 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}°`); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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(); |
|
|