Spaces:
Sleeping
Sleeping
| /* ═══════════════════════════════════════════════════════════════════════════ | |
| 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) : '·'; | |
| 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 · 6-stage cellular pipeline · 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>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; | |
| } | |