flvcko's picture
Biopesticide-AI: AMD Hackathon Unicorn Track submission
914512c
Raw
History Blame Contribute Delete
27.4 kB
/* ═══════════════════════════════════════════════════════════════════════════
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 =>
`<div class="stat"><div class="stat-num">${s.num}</div><div class="stat-lbl">${s.lbl}</div></div>`
).join('');
}
function renderPestReport(pest) {
const card = document.getElementById('pest-report-card');
if (!pest) { card.innerHTML = '<p style="color: var(--c-muted);">No pest report parsed.</p>'; 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 = `
<table class="pest-report-table">
<tr><td>Target species</td><td><b>${species}</b></td></tr>
<tr><td>Crop</td><td>${crop}</td></tr>
<tr><td>Severity</td><td>${severity}</td></tr>
<tr><td>Location</td><td>${location}</td></tr>
${notes ? `<tr><td>Notes</td><td style="color: var(--c-muted); font-style: italic;">${notes}</td></tr>` : ''}
</table>
`;
}
function renderCandidates(candidates) {
const grid = document.getElementById('candidates-grid');
if (!candidates || candidates.length === 0) {
grid.innerHTML = '<p style="color: var(--c-muted);">No candidates generated.</p>';
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 `
<div class="candidate-card ${rankClass}">
<div class="candidate-header">
<span class="candidate-rank">#${i + 1}</span>
<span class="candidate-seq">${c.sirna_seq || ''}</span>
</div>
<div class="candidate-metrics">
<span>Efficacy: <span class="metric-val">${effPct}%</span></span>
<span>Off-target: <span class="metric-val">${(c.offtarget_max || 0).toFixed(3)}</span></span>
<span>Half-life: <span class="metric-val">${hl.toFixed(1)}h (${hlDays}d)</span></span>
<span>Score: <span class="metric-val">${scorePct}%</span></span>
</div>
</div>
`;
}).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 = '<div class="heatmap-scroll"><table class="heatmap-table"><thead><tr><th>#</th>';
allSpecies.forEach(sp => {
const shortName = sp.split('_').map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
html += `<th title="${sp}">${shortName}</th>`;
});
html += '</tr></thead><tbody>';
candidates.forEach((c, i) => {
const perSp = c.offtarget_per_species || {};
html += `<tr><td class="heatmap-rank">${i + 1}</td>`;
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) : '&middot;';
const textColor = val > 0.05 ? '#ffffff' : '#2d2d2d';
html += `<td style="background: ${bg}; color: ${textColor};">${text}</td>`;
});
html += '</tr>';
});
html += '</tbody></table></div>';
container.innerHTML = html;
})
.catch(() => {});
}
function renderSafetyCards(safetyCards) {
const container = document.getElementById('safety-cards-content');
if (!safetyCards) { container.innerHTML = '<p style="color: var(--c-muted);">No safety cards generated.</p>'; return; }
if (typeof safetyCards === 'string') {
container.innerHTML = `<div class="safety-card-item"><div class="markdown-body">${simpleMarkdown(safetyCards)}</div></div>`;
return;
}
if (Array.isArray(safetyCards)) {
container.innerHTML = safetyCards.map((c, i) => {
if (typeof c === 'object' && c.card_markdown) {
return `<div class="safety-card-item">
<h4>Candidate #${i + 1}: ${c.sirna_seq || ''}</h4>
<div class="markdown-body">${simpleMarkdown(c.card_markdown)}</div>
</div>`;
}
return `<div class="safety-card-item"><div class="markdown-body">${simpleMarkdown(String(c))}</div></div>`;
}).join('');
return;
}
container.innerHTML = '<p style="color: var(--c-muted);">No safety cards generated.</p>';
}
function renderRegulatoryMemo(memo) {
const container = document.getElementById('regulatory-memo-content');
if (!memo) { container.innerHTML = '<p style="color: var(--c-muted);">No regulatory memo generated.</p>'; 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 = '<div class="loading-spinner"></div><p class="loading-text">Simulating cellular knockdown pipeline...</p>';
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 = `<div class="error-state"><p class="error-text">${e.message}</p></div>`;
} 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 = '<p style="color: var(--c-muted);">No simulation results.</p>';
return;
}
let html = '<div class="sim-summary-card"><h4 class="sim-card-title">Simulation summary</h4>';
html += '<p class="sim-meta">1000 Monte Carlo trials per candidate &middot; 6-stage cellular pipeline &middot; noise modeled at each step</p>';
html += '<table class="sim-table"><thead><tr><th>#</th><th>siRNA</th><th>Mean KD</th><th>95% CI</th><th>P(KD&gt;70%)</th><th>Delivery</th><th>Uptake</th><th>RISC</th><th>Tier</th></tr></thead><tbody>';
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 += `<tr>
<td class="heatmap-rank">${i + 1}</td>
<td class="sim-seq">${t.sirna_seq}</td>
<td class="sim-val-good">${meanKd}%</td>
<td class="sim-val">[${ciLow}, ${ciHigh}]%</td>
<td class="sim-val">${pGood}%</td>
<td class="sim-val">${delivery}%</td>
<td class="sim-val">${uptake}%</td>
<td class="sim-val">${risc}%</td>
<td class="${tierClass}">${tier}</td>
</tr>`;
});
html += '</tbody></table></div>';
// Add distribution chart
html += '<div class="sim-chart-card"><h4 class="sim-card-title">Knockdown distribution (top 5 candidates)</h4>';
html += '<div class="chart-container chart-container-tall"><canvas id="sim-distribution-chart"></canvas></div></div>';
// Add stage breakdown for top candidate
if (trials[0]) {
const top = trials[0];
html += '<div class="sim-chart-card"><h4 class="sim-card-title">6-stage pipeline breakdown (top candidate)</h4>';
html += '<div class="sim-stages">';
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 += `<div class="sim-stage">
<div class="sim-stage-header">
<span class="sim-stage-name">${s.name}</span>
<span class="sim-stage-val">${pct}%</span>
</div>
<div class="sim-stage-bar"><div class="sim-stage-fill" style="width: ${pct}%"></div></div>
<p class="sim-stage-desc">${s.desc}</p>
</div>`;
});
html += '</div></div>';
}
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, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
html = '<p>' + html + '</p>';
html = html.replace(/<p><\/p>/g, '');
return html;
}