/* ═══════════════════════════════════════════════════════════════════════════ Biopesticide-AI — Frontend Logic v2 Fixes: chart containers now have explicit heights, charts render properly. Adds: wet-lab simulation tab with Monte Carlo results rendering. ═══════════════════════════════════════════════════════════════════════════ */ let lastResult = null; let charts = { efficacy: null, halflife: null }; document.addEventListener('DOMContentLoaded', () => { fetchStatus(); setupExamples(); setupTabs(); setupExport(); setupPestCards(); document.getElementById('design-btn').addEventListener('click', runDesign); document.getElementById('simulate-btn').addEventListener('click', runSimulation); // Custom input mode: Ctrl+Enter to submit const pestReport = document.getElementById('pest-report'); if (pestReport) { pestReport.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'Enter') runDesign(); }); } }); function setupPestCards() { document.querySelectorAll('.pest-card').forEach(card => { card.addEventListener('click', (e) => { if (card.id === 'custom-input-btn') { // Reveal the custom input card document.getElementById('custom-input-card').style.display = 'block'; document.getElementById('card-mode-controls').style.display = 'none'; document.getElementById('pest-report').focus(); return; } // Pest card clicked: run design immediately with this species const species = card.dataset.species; const crop = card.dataset.crop; const prompt = card.dataset.prompt; // Highlight the selected card document.querySelectorAll('.pest-card').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); runDesignFromCard(species, crop, prompt); }); }); } async function runDesignFromCard(species, crop, prompt) { const topK = parseInt(document.getElementById('top-k-card').value) || 10; const loadingState = document.getElementById('loading-state'); const errorState = document.getElementById('error-state'); const resultsSection = document.getElementById('results-section'); loadingState.style.display = 'block'; errorState.style.display = 'none'; resultsSection.style.display = 'none'; const loadingSteps = [ `Loading ${species.replace(/_/g, ' ')} transcripts...`, 'Tiling into 200-nt dsRNA precursors...', 'Dicing into 21-nt siRNAs...', 'Scoring efficacy with dilated CNN...', 'Checking off-target risk against 14 species...', 'Predicting environmental half-life with PINN...', 'Ranking candidates...', 'Generating safety cards with Llama 3.2 3B...', 'Generating regulatory memo...' ]; let stepIdx = 0; const loadingInterval = setInterval(() => { document.getElementById('loading-subtext').textContent = loadingSteps[stepIdx % loadingSteps.length]; stepIdx++; }, 800); try { const resp = await fetch('/api/design', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_text: prompt, top_k: topK, pest_species: species, // bypasses LLM parsing }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail || `HTTP ${resp.status}`); } const result = await resp.json(); lastResult = result; loadingState.style.display = 'none'; resultsSection.style.display = 'block'; renderStats(result); renderPestReport(result.pest_report); renderCandidates(result.candidates); renderCharts(result.candidates); renderOffTargetHeatmap(result.candidates); renderSafetyCards(result.safety_cards); renderRegulatoryMemo(result.regulatory_memo); document.getElementById('simulation-results').innerHTML = ''; resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { loadingState.style.display = 'none'; errorState.style.display = 'block'; document.getElementById('error-text').textContent = e.message; } finally { clearInterval(loadingInterval); } } async function fetchStatus() { try { const resp = await fetch('/api/status'); const data = await resp.json(); const dot = document.getElementById('status-dot'); const text = document.getElementById('status-text'); if (data.llm_active) { dot.className = 'status-dot active'; text.textContent = data.llm_status; } else { dot.className = 'status-dot degraded'; text.textContent = data.llm_status; } } catch (e) { document.getElementById('status-dot').className = 'status-dot error'; document.getElementById('status-text').textContent = 'Backend offline'; } } function setupExamples() { document.querySelectorAll('.example-chip').forEach(chip => { chip.addEventListener('click', () => { document.getElementById('pest-report').value = chip.dataset.text; document.getElementById('pest-report').focus(); }); }); } function setupTabs() { document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); document.getElementById('tab-' + tab.dataset.tab).classList.add('active'); }); }); } async function runDesign() { const userText = document.getElementById('pest-report').value.trim(); const topK = parseInt(document.getElementById('top-k').value) || 10; if (!userText) { alert('Please describe your pest problem first.'); return; } const btn = document.getElementById('design-btn'); const loadingState = document.getElementById('loading-state'); const errorState = document.getElementById('error-state'); const resultsSection = document.getElementById('results-section'); btn.disabled = true; btn.querySelector('.design-btn-text').textContent = 'Designing...'; loadingState.style.display = 'block'; errorState.style.display = 'none'; resultsSection.style.display = 'none'; const loadingSteps = [ 'Parsing pest report with Llama 3.2 3B...', 'Tiling pest transcripts into 200-nt precursors...', 'Dicing into 21-nt siRNAs...', 'Scoring efficacy with dilated CNN...', 'Checking off-target risk against 14 species...', 'Predicting environmental half-life with PINN...', 'Ranking candidates...', 'Generating safety cards with Llama 3.2 3B...', 'Generating regulatory memo...' ]; let stepIdx = 0; const loadingInterval = setInterval(() => { document.getElementById('loading-subtext').textContent = loadingSteps[stepIdx % loadingSteps.length]; stepIdx++; }, 1500); try { const resp = await fetch('/api/design', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_text: userText, top_k: topK }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail || `HTTP ${resp.status}`); } const result = await resp.json(); lastResult = result; loadingState.style.display = 'none'; resultsSection.style.display = 'block'; renderStats(result); renderPestReport(result.pest_report); renderCandidates(result.candidates); renderCharts(result.candidates); renderOffTargetHeatmap(result.candidates); renderSafetyCards(result.safety_cards); renderRegulatoryMemo(result.regulatory_memo); // Reset simulation results document.getElementById('simulation-results').innerHTML = ''; resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { loadingState.style.display = 'none'; errorState.style.display = 'block'; document.getElementById('error-text').textContent = e.message; } finally { clearInterval(loadingInterval); btn.disabled = false; btn.querySelector('.design-btn-text').textContent = 'Design dsRNA candidates'; } } function renderStats(result) { const strip = document.getElementById('stats-strip'); const stats = [ { num: `${result.elapsed_seconds}s`, lbl: 'Design loop' }, { num: result.n_transcripts, lbl: 'Transcripts' }, { num: result.n_precursors, lbl: 'Precursors' }, { num: result.n_sirnas, lbl: 'siRNAs scored' }, { num: `$${result.total_cost_estimate.toFixed(4)}`, lbl: 'Est. cost' }, ]; strip.innerHTML = stats.map(s => `
${s.num}
${s.lbl}
` ).join(''); } function renderPestReport(pest) { const card = document.getElementById('pest-report-card'); if (!pest) { card.innerHTML = '

No pest report parsed.

'; return; } const species = pest.pest_species || pest.species || 'unknown'; const crop = pest.crop || 'unknown'; const severity = pest.severity || 'unknown'; const location = pest.location || 'unknown'; const notes = pest.notes || ''; card.innerHTML = ` ${notes ? `` : ''}
Target species${species}
Crop${crop}
Severity${severity}
Location${location}
Notes${notes}
`; } function renderCandidates(candidates) { const grid = document.getElementById('candidates-grid'); if (!candidates || candidates.length === 0) { grid.innerHTML = '

No candidates generated.

'; return; } grid.innerHTML = candidates.map((c, i) => { const score = c.final_score || 0; let rankClass = 'rank-good'; if (score < 0.3) rankClass = 'rank-warn'; if (score < 0.15) rankClass = 'rank-poor'; const hl = c.half_life_hours || 0; const hlDays = (hl / 24).toFixed(1); const effPct = ((c.efficacy || 0) * 100).toFixed(1); const scorePct = (score * 100).toFixed(1); return `
#${i + 1} ${c.sirna_seq || ''}
Efficacy: ${effPct}% Off-target: ${(c.offtarget_max || 0).toFixed(3)} Half-life: ${hl.toFixed(1)}h (${hlDays}d) Score: ${scorePct}%
`; }).join(''); } function renderCharts(candidates) { if (!candidates || candidates.length === 0) return; Object.values(charts).forEach(c => { if (c) c.destroy(); }); const labels = candidates.map((c, i) => `#${i + 1} ${(c.sirna_seq || '').substring(0, 6)}`); const efficacies = candidates.map(c => c.efficacy || 0); const halfLives = candidates.map(c => c.half_life_hours || 0); const scores = candidates.map(c => c.final_score || 0); // Efficacy chart — show as percentage (0-100) const effCtx = document.getElementById('efficacy-chart').getContext('2d'); charts.efficacy = new Chart(effCtx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Efficacy (%)', data: efficacies.map(e => e * 100), backgroundColor: scores.map(s => s > 0.3 ? '#7fb069' : '#6b7280'), borderRadius: 4, }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => `Efficacy: ${ctx.parsed.x.toFixed(1)}%` } } }, scales: { x: { beginAtZero: true, max: 100, grid: { color: '#e5e7eb' }, ticks: { callback: (v) => v + '%' } }, y: { grid: { display: false } } } } }); // Half-life chart — hours, with risk-tier colors and reference lines const hlCtx = document.getElementById('halflife-chart').getContext('2d'); charts.halflife = new Chart(hlCtx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Half-life (hours)', data: halfLives, backgroundColor: halfLives.map(h => h < 24 ? '#964039' : h < 72 ? '#449f63' : '#4c7094'), borderRadius: 4, }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => `${ctx.parsed.x.toFixed(1)}h (${(ctx.parsed.x/24).toFixed(1)} days)` } }, annotation: {} // placeholder; Chart.js v4 needs plugin for lines }, scales: { x: { beginAtZero: true, grid: { color: '#e5e7eb' }, ticks: { callback: (v) => v + 'h' } }, y: { grid: { display: false } } } } }); } function renderOffTargetHeatmap(candidates) { if (!candidates || candidates.length === 0) return; fetch('/api/status') .then(r => r.json()) .then(data => { const allSpecies = data.safety_species; const container = document.getElementById('heatmap-container'); if (!container) return; const nCand = candidates.length; let html = '
'; allSpecies.forEach(sp => { const shortName = sp.split('_').map(w => w[0].toUpperCase() + w.slice(1)).join(' '); html += ``; }); html += ''; candidates.forEach((c, i) => { const perSp = c.offtarget_per_species || {}; html += ``; allSpecies.forEach(sp => { const val = perSp[sp] || 0; let bg = '#ffffff'; if (val > 0.1) bg = '#964039'; else if (val > 0.05) bg = '#b69045'; else if (val > 0.01) bg = '#fef6e4'; const text = val > 0 ? val.toFixed(3) : '·'; const textColor = val > 0.05 ? '#ffffff' : '#2d2d2d'; html += ``; }); html += ''; }); html += '
#${shortName}
${i + 1}${text}
'; container.innerHTML = html; }) .catch(() => {}); } function renderSafetyCards(safetyCards) { const container = document.getElementById('safety-cards-content'); if (!safetyCards) { container.innerHTML = '

No safety cards generated.

'; return; } if (typeof safetyCards === 'string') { container.innerHTML = `
${simpleMarkdown(safetyCards)}
`; return; } if (Array.isArray(safetyCards)) { container.innerHTML = safetyCards.map((c, i) => { if (typeof c === 'object' && c.card_markdown) { return `

Candidate #${i + 1}: ${c.sirna_seq || ''}

${simpleMarkdown(c.card_markdown)}
`; } return `
${simpleMarkdown(String(c))}
`; }).join(''); return; } container.innerHTML = '

No safety cards generated.

'; } function renderRegulatoryMemo(memo) { const container = document.getElementById('regulatory-memo-content'); if (!memo) { container.innerHTML = '

No regulatory memo generated.

'; return; } container.innerHTML = simpleMarkdown(memo); } // ─── Wet-lab simulation ───────────────────────────────────────────────────── async function runSimulation() { if (!lastResult || !lastResult.candidates || lastResult.candidates.length === 0) { alert('Run the design pipeline first.'); return; } const btn = document.getElementById('simulate-btn'); const resultsDiv = document.getElementById('simulation-results'); btn.disabled = true; btn.textContent = 'Running 1000-trial Monte Carlo simulation...'; resultsDiv.innerHTML = '

Simulating cellular knockdown pipeline...

'; try { const resp = await fetch('/api/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ candidates: lastResult.candidates.slice(0, 5), pest_species: lastResult.pest_report?.pest_species || 'nilaparvata_lugens', }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const result = await resp.json(); renderSimulationResults(result); } catch (e) { resultsDiv.innerHTML = `

${e.message}

`; } finally { btn.disabled = false; btn.textContent = 'Run wet-lab simulation'; } } function renderSimulationResults(result) { const div = document.getElementById('simulation-results'); const trials = result.trials || []; if (!trials || trials.length === 0) { div.innerHTML = '

No simulation results.

'; return; } let html = '

Simulation summary

'; html += '

1000 Monte Carlo trials per candidate · 6-stage cellular pipeline · noise modeled at each step

'; html += ''; trials.forEach((t, i) => { const meanKd = (t.mean_knockdown * 100).toFixed(1); const ciLow = (t.ci_low * 100).toFixed(1); const ciHigh = (t.ci_high * 100).toFixed(1); const pGood = (t.prob_above_70 * 100).toFixed(0); const delivery = (t.delivery_efficiency * 100).toFixed(0); const uptake = (t.uptake_efficiency * 100).toFixed(0); const risc = (t.risc_loading * 100).toFixed(0); let tier = 'Promising'; let tierClass = 'tier-good'; if (t.mean_knockdown < 0.5) { tier = 'Marginal'; tierClass = 'tier-warn'; } if (t.mean_knockdown < 0.3) { tier = 'Unlikely'; tierClass = 'tier-poor'; } html += ``; }); html += '
#siRNAMean KD95% CIP(KD>70%)DeliveryUptakeRISCTier
${i + 1} ${t.sirna_seq} ${meanKd}% [${ciLow}, ${ciHigh}]% ${pGood}% ${delivery}% ${uptake}% ${risc}% ${tier}
'; // Add distribution chart html += '

Knockdown distribution (top 5 candidates)

'; html += '
'; // Add stage breakdown for top candidate if (trials[0]) { const top = trials[0]; html += '

6-stage pipeline breakdown (top candidate)

'; html += '
'; const stages = [ { name: 'Delivery', val: top.delivery_efficiency, desc: 'dsRNA reaches target cells (modeled by PINN half-life)' }, { name: 'Uptake', val: top.uptake_efficiency, desc: 'Cellular uptake efficiency (length + GC dependent)' }, { name: 'Dicer', val: top.dicer_efficiency, desc: 'Dicer processing into 21-nt siRNA' }, { name: 'RISC loading', val: top.risc_loading, desc: 'Guide strand loading (thermodynamic asymmetry)' }, { name: 'Target cleavage', val: top.cleavage_rate, desc: 'CNN-predicted mRNA cleavage efficiency' }, { name: 'Phenotype', val: top.phenotypic_response, desc: 'Mortality / growth reduction response curve' }, ]; stages.forEach(s => { const pct = (s.val * 100).toFixed(0); html += `
${s.name} ${pct}%

${s.desc}

`; }); html += '
'; } div.innerHTML = html; // Render the distribution chart renderSimDistributionChart(trials); } function renderSimDistributionChart(trials) { const ctx = document.getElementById('sim-distribution-chart'); if (!ctx) return; const labels = trials.map((t, i) => `#${i + 1} ${t.sirna_seq.substring(0, 6)}`); const means = trials.map(t => t.mean_knockdown * 100); const ciLows = trials.map(t => t.ci_low * 100); const ciHighs = trials.map(t => t.ci_high * 100); new Chart(ctx.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: [{ label: 'Mean knockdown (%)', data: means, backgroundColor: means.map(m => m > 70 ? '#449f63' : m > 50 ? '#7fb069' : m > 30 ? '#b69045' : '#964039'), borderRadius: 4, }, { label: '95% CI lower', data: ciLows, type: 'line', borderColor: '#1a3c34', backgroundColor: 'transparent', pointRadius: 3, borderWidth: 1, }, { label: '95% CI upper', data: ciHighs, type: 'line', borderColor: '#1a3c34', backgroundColor: 'transparent', pointRadius: 3, borderWidth: 1, }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { font: { size: 11 } } }, tooltip: { callbacks: { label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%` } } }, scales: { y: { beginAtZero: true, max: 100, grid: { color: '#e5e7eb' }, ticks: { callback: v => v + '%' } }, x: { grid: { display: false } } } } }); } function setupExport() { document.getElementById('export-csv-btn').addEventListener('click', () => { if (!lastResult || !lastResult.candidates) return; const rows = [['rank', 'sirna_seq', 'efficacy', 'offtarget_max', 'half_life_hours', 'final_score']]; lastResult.candidates.forEach((c, i) => { rows.push([i + 1, c.sirna_seq || '', (c.efficacy || 0).toFixed(4), (c.offtarget_max || 0).toFixed(4), (c.half_life_hours || 0).toFixed(2), (c.final_score || 0).toFixed(4)]); }); const csv = rows.map(r => r.join(',')).join('\n'); downloadFile(csv, 'biopesticide_candidates.csv', 'text/csv'); }); document.getElementById('export-json-btn').addEventListener('click', () => { if (!lastResult) return; const slim = { ...lastResult }; if (slim.candidates) slim.candidates = slim.candidates.slice(0, 10); downloadFile(JSON.stringify(slim, null, 2), 'biopesticide_design.json', 'application/json'); }); } function downloadFile(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function simpleMarkdown(md) { if (!md) return ''; let html = escapeHtml(md); html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/\*(.+?)\*/g, '$1'); html = html.replace(/`(.+?)`/g, '$1'); html = html.replace(/\n\n/g, '

'); html = html.replace(/\n/g, '
'); html = '

' + html + '

'; html = html.replace(/

<\/p>/g, ''); return html; }