// script.js // Purpose: Frontend logic for ECG Arrhythmia Detection Web App // Updated: Added full explanation panel rendering // Handles: file upload, API call, all charts, clinical summary, explanation // ================================================ // SECTION 1: Global Variables // ================================================ let selectedFiles = {}; let rfChart = null; let cnnChart = null; let agreeChart = null; let hrChart = null; let accChart = null; // ================================================ // SECTION 2: File Upload Handling // ================================================ document.getElementById('file-input').addEventListener('change', function(e) { handleFiles(e.target.files); }); // Drag and drop const dropZone = document.getElementById('drop-zone'); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('drag-over'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); handleFiles(e.dataTransfer.files); }); function handleFiles(files) { selectedFiles = {}; Array.from(files).forEach(file => { const ext = file.name.split('.').pop().toLowerCase(); if (['dat', 'hea', 'atr'].includes(ext)) { selectedFiles[ext] = file; } }); renderFileList(); updateAnalyzeButton(); } function renderFileList() { const list = document.getElementById('file-list'); list.innerHTML = ''; const required = ['dat', 'hea', 'atr']; const labels = { dat: '📊 Signal Data (.dat)', hea: '📋 Header Info (.hea)', atr: '🏷️ Annotations (.atr)' }; required.forEach(ext => { const file = selectedFiles[ext]; const item = document.createElement('div'); item.className = 'file-item'; if (file) { const size = (file.size / 1024).toFixed(1); item.innerHTML = ` ${labels[ext].split(' ')[0]} ${file.name} ${size} KB `; } else { item.style.opacity = '0.4'; item.innerHTML = ` 📄 ${labels[ext]} — not selected ${ext === 'atr' ? '(optional)' : '(required)'} `; } list.appendChild(item); }); } function updateAnalyzeButton() { const btn = document.getElementById('btn-analyze'); btn.disabled = !(selectedFiles['dat'] && selectedFiles['hea']); } // ================================================ // SECTION 3: Analyze ECG // ================================================ async function analyzeECG() { if (!selectedFiles['dat'] || !selectedFiles['hea']) { alert('Please select at least .dat and .hea files'); return; } showSection('loading'); animateLoadingSteps(); try { const formData = new FormData(); formData.append('dat_file', selectedFiles['dat']); formData.append('hea_file', selectedFiles['hea']); if (selectedFiles['atr']) { formData.append('atr_file', selectedFiles['atr']); } const response = await fetch('/analyze', { method: 'POST', body : formData }); const data = await response.json(); if (data.error) { alert('Error: ' + data.error); showSection('upload'); return; } renderResults(data); showSection('results'); } catch (err) { alert('Connection error: ' + err.message); showSection('upload'); } } // ================================================ // SECTION 4: Loading Animation // ================================================ function animateLoadingSteps() { const steps = ['step-1','step-2','step-3','step-4','step-5','step-6']; const labels = [ '✅ Filtering signal...', '✅ Detecting R-peaks...', '✅ Extracting features...', '✅ Running Random Forest...', '✅ Running CNN...', '✅ Generating report...' ]; // Reset all steps first steps.forEach(id => { const el = document.getElementById(id); el.className = 'loading-step'; }); steps.forEach((id, i) => { setTimeout(() => { if (i > 0) { const prev = document.getElementById(steps[i-1]); prev.className = 'loading-step done'; prev.textContent = labels[i-1]; } const curr = document.getElementById(id); curr.className = 'loading-step active'; }, i * 800); }); } // ================================================ // SECTION 5: Render All Results // ================================================ function renderResults(data) { // Verdict renderVerdict(data.verdict); // Info bar document.getElementById('info-record').textContent = `Record ${data.record_name}`; document.getElementById('info-duration').textContent = `${data.duration_min} min`; document.getElementById('info-beats').textContent = data.total_beats.toLocaleString(); document.getElementById('info-rf-acc').textContent = data.rf_acc !== null ? `${data.rf_acc}%` : 'N/A'; document.getElementById('info-cnn-acc').textContent = data.cnn_acc !== null ? `${data.cnn_acc}%` : 'N/A'; document.getElementById('info-agreement').textContent = `${data.agree_pct}%`; // ECG waveform document.getElementById('ecg-plot').src = 'data:image/png;base64,' + data.ecg_plot; // Charts renderBeatChart( 'rf-chart', data.rf_counts, rfChart, c => rfChart = c ); renderBeatChart( 'cnn-chart', data.cnn_counts, cnnChart, c => cnnChart = c ); renderAgreeChart(data.agree_pct); renderHRChart( data.time_minutes, data.hr_values, data.hr_labels ); renderAccChart(data.rf_acc, data.cnn_acc); // Clinical summary renderClinicalSummary(data); // Per class table renderPerClassTable(data.per_class); // Explanation panel renderExplanation(data.explanation); } // ================================================ // SECTION 6: Verdict Banner // ================================================ function renderVerdict(verdict) { const banner = document.getElementById('verdict-banner'); const icon = document.getElementById('verdict-icon'); const title = document.getElementById('verdict-title'); const detail = document.getElementById('verdict-detail'); banner.classList.remove('success', 'warning', 'danger'); banner.classList.add(verdict.type); icon.textContent = verdict.icon; title.textContent = verdict.verdict; detail.textContent = verdict.detail; } // ================================================ // SECTION 7: Beat Distribution Charts // ================================================ function renderBeatChart(canvasId, counts, existingChart, setChart) { const labels = Object.keys(counts); const values = Object.values(counts); const total = values.reduce((a, b) => a + b, 0); const colorMap = { N: '#2ecc71', A: '#e67e22', V: '#e74c3c' }; const colors = labels.map(l => colorMap[l] || '#888888'); if (existingChart) existingChart.destroy(); const ctx = document.getElementById(canvasId).getContext('2d'); const chart = new Chart(ctx, { type: 'doughnut', data: { labels : labels.map(l => { const names = { N: 'Normal', A: 'Atrial', V: 'Ventricular' }; return `${names[l] || l}: ${counts[l]}`; }), datasets: [{ data : values, backgroundColor: colors, borderColor : '#1a1a2e', borderWidth : 3, hoverOffset : 8 }] }, options: { responsive : true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels : { color : '#aaaaaa', font : { size: 11 }, padding : 12, boxWidth: 12, } }, tooltip: { callbacks: { label: (ctx) => { const pct = ((ctx.parsed / total) * 100).toFixed(1); return ` ${ctx.parsed} beats (${pct}%)`; } } } } } }); setChart(chart); } // ================================================ // SECTION 8: Agreement Chart // ================================================ function renderAgreeChart(agreePct) { if (agreeChart) agreeChart.destroy(); const ctx = document.getElementById('agree-chart').getContext('2d'); agreeChart = new Chart(ctx, { type: 'doughnut', data: { labels: [ `Agree: ${agreePct}%`, `Disagree: ${(100 - agreePct).toFixed(1)}%` ], datasets: [{ data : [agreePct, 100 - agreePct], backgroundColor: ['#2ecc71', '#e74c3c'], borderColor : '#1a1a2e', borderWidth : 3, hoverOffset : 8 }] }, options: { responsive : true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels : { color : '#aaaaaa', font : { size: 11 }, padding : 12, boxWidth: 12, } } } } }); } // ================================================ // SECTION 9: Heart Rate Chart // ================================================ function renderHRChart(timeMinutes, hrValues, hrLabels) { if (hrChart) hrChart.destroy(); const colorMap = { N: '#2ecc71', A: '#e67e22', V: '#e74c3c' }; const names = { N: 'Normal', A: 'Atrial', V: 'Ventricular' }; const classes = ['N', 'A', 'V']; const datasets = classes.map(cls => { const points = timeMinutes .map((t, i) => hrLabels[i] === cls ? { x: t, y: hrValues[i] } : null) .filter(Boolean); return { label : names[cls], data : points, backgroundColor : colorMap[cls], pointRadius : 2, pointHoverRadius: 5, }; }); // Threshold lines const minT = Math.min(...timeMinutes); const maxT = Math.max(...timeMinutes); datasets.push({ label : '60 BPM', data : [{ x: minT, y: 60 }, { x: maxT, y: 60 }], type : 'line', borderColor: '#f1c40f', borderWidth: 1, borderDash : [5, 5], pointRadius: 0, fill : false, }); datasets.push({ label : '100 BPM', data : [{ x: minT, y: 100 }, { x: maxT, y: 100 }], type : 'line', borderColor: '#e74c3c', borderWidth: 1, borderDash : [5, 5], pointRadius: 0, fill : false, }); const ctx = document.getElementById('hr-chart').getContext('2d'); hrChart = new Chart(ctx, { type: 'scatter', data: { datasets }, options: { responsive : true, maintainAspectRatio: false, scales: { x: { title: { display: true, text : 'Time (minutes)', color : '#aaaaaa' }, ticks: { color: '#aaaaaa' }, grid : { color: 'rgba(255,255,255,0.05)' } }, y: { title: { display: true, text : 'Heart Rate (BPM)', color : '#aaaaaa' }, ticks: { color: '#aaaaaa' }, grid : { color: 'rgba(255,255,255,0.05)' } } }, plugins: { legend: { labels: { color : '#aaaaaa', font : { size: 11 }, boxWidth: 12, } } } } }); } // ================================================ // SECTION 10: Model Accuracy Comparison Chart // ================================================ function renderAccChart(rfAcc, cnnAcc) { if (accChart) accChart.destroy(); const ctx = document.getElementById('acc-chart').getContext('2d'); accChart = new Chart(ctx, { type: 'bar', data: { labels : ['SVM', 'Random Forest', 'CNN'], datasets: [{ label : 'Test Accuracy (%)', data : [83.33, rfAcc || 85.0, cnnAcc || 97.38], backgroundColor: ['#3498db', '#2ecc71', '#9b59b6'], borderColor : ['#2980b9', '#27ae60', '#8e44ad'], borderWidth : 1, borderRadius : 6, }] }, options: { responsive : true, maintainAspectRatio: false, scales: { y: { min : 60, max : 105, ticks: { color: '#aaaaaa' }, grid : { color: 'rgba(255,255,255,0.05)' } }, x: { ticks: { color: '#aaaaaa' }, grid : { color: 'transparent' } } }, plugins: { legend : { display: false }, tooltip: { callbacks: { label: (ctx) => ` ${ctx.parsed.y.toFixed(1)}%` } } } } }); } // ================================================ // SECTION 11: Clinical Summary // ================================================ function renderClinicalSummary(data) { document.getElementById('sum-avg-hr').textContent = `${data.avg_hr} BPM`; document.getElementById('sum-min-hr').textContent = `${data.min_hr} BPM`; document.getElementById('sum-max-hr').textContent = `${data.max_hr} BPM`; document.getElementById('sum-avg-rr').textContent = `${data.avg_rr} ms`; document.getElementById('sum-avg-st').textContent = `${data.avg_st} mV`; const hrStatus = document.getElementById('sum-hr-status'); hrStatus.textContent = data.hr_status; hrStatus.className = 'summary-status ' + ( data.hr_status === 'NORMAL' ? 'status-normal' : 'status-abnormal' ); const stStatus = document.getElementById('sum-st-status'); stStatus.textContent = data.st_status; stStatus.className = 'summary-status ' + ( data.st_status === 'NORMAL' ? 'status-normal' : 'status-abnormal' ); const rfRisk = document.getElementById('sum-rf-risk'); rfRisk.textContent = data.rf_risk; rfRisk.className = 'summary-value ' + getRiskClass(data.rf_risk); const cnnRisk = document.getElementById('sum-cnn-risk'); cnnRisk.textContent = data.cnn_risk; cnnRisk.className = 'summary-value ' + getRiskClass(data.cnn_risk); } function getRiskClass(risk) { if (risk === 'LOW RISK') return 'status-risk-low'; if (risk === 'MODERATE') return 'status-risk-moderate'; if (risk === 'HIGH RISK') return 'status-risk-high'; return ''; } // ================================================ // SECTION 12: Per Class Accuracy Table // ================================================ function renderPerClassTable(perClass) { const container = document.getElementById('per-class-table'); container.innerHTML = ''; if (!perClass || Object.keys(perClass).length === 0) { container.innerHTML = '

' + 'No ground truth available for accuracy calculation

'; return; } const colorMap = { N: '#2ecc71', A: '#e67e22', V: '#e74c3c' }; const nameMap = { N: 'Normal', A: 'Atrial', V: 'Ventricular' }; Object.entries(perClass).forEach(([label, info]) => { const row = document.createElement('div'); row.className = 'per-class-row'; row.innerHTML = `
${nameMap[label]}
RF
${info.rf_acc}%
CNN
${info.cnn_acc}%
${info.count} beats `; container.appendChild(row); }); } // ================================================ // SECTION 13: Explanation Panel // ================================================ function renderExplanation(exp) { // --- Why detected --- const whyList = document.getElementById('exp-why'); whyList.innerHTML = ''; exp.why.forEach(item => { const li = document.createElement('li'); li.textContent = item; whyList.appendChild(li); }); // --- Features triggered --- const featList = document.getElementById('exp-features'); featList.innerHTML = ''; exp.features_triggered.forEach(item => { const li = document.createElement('li'); li.textContent = item; featList.appendChild(li); }); // --- Beat breakdown cards --- const breakdown = document.getElementById('exp-breakdown'); breakdown.innerHTML = '
'; const grid = breakdown.querySelector('.breakdown-grid'); const colorMap = { green : '#2ecc71', orange: '#e67e22', red : '#e74c3c' }; exp.breakdown.forEach(beat => { const card = document.createElement('div'); card.className = `breakdown-card ${beat.color}`; const fillColor = colorMap[beat.color] || '#888'; card.innerHTML = `
${beat.type}
${beat.pct}%
RF: ${beat.rf} beats CNN: ${beat.cnn} beats
${beat.meaning}
`; grid.appendChild(card); }); // --- Clinical interpretation --- const clinical = document.getElementById('exp-clinical'); clinical.innerHTML = ''; exp.clinical.forEach(item => { const div = document.createElement('div'); div.className = 'clinical-item'; div.textContent = item; clinical.appendChild(div); }); // --- Next steps --- const steps = document.getElementById('exp-steps'); steps.innerHTML = ''; exp.next_steps.forEach(item => { const li = document.createElement('li'); li.textContent = item; steps.appendChild(li); }); } // ================================================ // SECTION 14: Section Switching // ================================================ function showSection(section) { document.getElementById('upload-section').classList.add('hidden'); document.getElementById('loading-section').classList.add('hidden'); document.getElementById('results-section').classList.add('hidden'); if (section === 'upload') { document.getElementById('upload-section') .classList.remove('hidden'); } else if (section === 'loading') { document.getElementById('loading-section') .classList.remove('hidden'); } else if (section === 'results') { document.getElementById('results-section') .classList.remove('hidden'); window.scrollTo({ top: 0, behavior: 'smooth' }); } } // ================================================ // SECTION 15: Reset App // ================================================ function resetApp() { selectedFiles = {}; document.getElementById('file-input').value = ''; document.getElementById('file-list').innerHTML = ''; document.getElementById('btn-analyze').disabled = true; // Destroy all charts [rfChart, cnnChart, agreeChart, hrChart, accChart].forEach(c => { if (c) c.destroy(); }); rfChart = cnnChart = agreeChart = hrChart = accChart = null; showSection('upload'); window.scrollTo({ top: 0, behavior: 'smooth' }); }