| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Multimodal Heart Attack Risk</title> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg: #0b0f1a; |
| --surface: rgba(255,255,255,0.04); |
| --surface-2: rgba(255,255,255,0.06); |
| --border: rgba(255,255,255,0.08); |
| --text: #e2e8f0; |
| --text-dim: #8892a4; |
| --accent: #6366f1; |
| --accent-g: linear-gradient(135deg, #6366f1, #ec4899); |
| --high: #ef4444; |
| --moderate: #f59e0b; |
| --low: #22c55e; |
| --radius: 14px; |
| } |
| |
| body { |
| font-family: 'Inter', sans-serif; |
| background: var(--bg); |
| color: var(--text); |
| min-height: 100vh; |
| display: flex; |
| align-items: flex-start; |
| justify-content: center; |
| padding: 2.5rem 1rem; |
| overflow-x: hidden; |
| } |
| |
| body::before, body::after { |
| content: ''; position: fixed; pointer-events: none; z-index: 0; |
| filter: blur(80px); |
| } |
| body::before { |
| top: -40%; left: -10%; width: 60vw; height: 60vw; |
| background: radial-gradient(circle, rgba(99,102,241,0.18), transparent 70%); |
| animation: float 8s ease-in-out infinite alternate; |
| } |
| body::after { |
| bottom: -30%; right: -10%; width: 50vw; height: 50vw; |
| background: radial-gradient(circle, rgba(236,72,153,0.14), transparent 70%); |
| animation: float 10s ease-in-out infinite alternate-reverse; |
| } |
| @keyframes float { to { transform: translate(40px, -30px) scale(1.05); } } |
| |
| .card { |
| position: relative; z-index: 1; |
| width: 100%; max-width: 600px; |
| background: var(--surface); |
| backdrop-filter: blur(24px) saturate(140%); |
| -webkit-backdrop-filter: blur(24px) saturate(140%); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 2.5rem 2rem 2rem; |
| } |
| |
| .header { text-align: center; margin-bottom: 1.75rem; } |
| .header .icon { |
| width: 56px; height: 56px; margin: 0 auto .75rem; |
| background: var(--accent-g); border-radius: 16px; |
| display: grid; place-items: center; font-size: 28px; |
| box-shadow: 0 4px 20px rgba(99,102,241,0.35); |
| } |
| .header h1 { |
| font-size: 1.35rem; font-weight: 700; |
| background: var(--accent-g); |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| } |
| .header p { font-size: .82rem; color: var(--text-dim); margin-top: .3rem; } |
| |
| .section-title { |
| font-size: .7rem; font-weight: 600; letter-spacing: .08em; |
| text-transform: uppercase; color: var(--text-dim); |
| margin: 1.5rem 0 .75rem; display: flex; align-items: center; gap: .5rem; |
| } |
| .section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); } |
| |
| .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .85rem; } |
| .field { display: flex; flex-direction: column; } |
| .field.full { grid-column: 1 / -1; } |
| |
| label { |
| font-size: .72rem; font-weight: 500; color: var(--text-dim); |
| text-transform: uppercase; letter-spacing: .05em; margin-bottom: .3rem; |
| display: flex; gap: .4rem; align-items: baseline; |
| } |
| label .opt { font-size: .62rem; color: var(--accent); text-transform: none; letter-spacing: 0; } |
| |
| input, select { |
| background: rgba(255,255,255,0.05); border: 1px solid var(--border); |
| border-radius: 10px; padding: .6rem .8rem; color: var(--text); |
| font-family: inherit; font-size: .88rem; outline: none; |
| transition: border-color .2s, box-shadow .2s; |
| } |
| input:focus, select:focus { |
| border-color: var(--accent); box-shadow: 0 0 0 3px rgba(99,102,241,0.2); |
| } |
| input::placeholder { color: rgba(255,255,255,0.2); } |
| select option { background: #1e1e2e; color: var(--text); } |
| |
| |
| .drop { |
| border: 1.5px dashed var(--border); border-radius: 12px; |
| padding: 1.25rem; text-align: center; cursor: pointer; |
| transition: border-color .2s, background .2s; color: var(--text-dim); |
| font-size: .85rem; |
| } |
| .drop:hover, .drop.drag { border-color: var(--accent); background: rgba(99,102,241,0.06); } |
| .drop input { display: none; } |
| .drop .preview { max-height: 150px; max-width: 100%; border-radius: 8px; margin-top: .5rem; } |
| .drop .filename { color: var(--text); font-weight: 500; margin-top: .4rem; word-break: break-all; } |
| |
| |
| .mode { |
| margin: 1.1rem 0 .25rem; font-size: .78rem; text-align: center; |
| color: var(--text-dim); |
| } |
| .mode b { color: var(--accent); } |
| |
| .btn { |
| width: 100%; margin-top: .75rem; padding: .8rem; border: none; |
| border-radius: 10px; background: var(--accent-g); color: #fff; |
| font-family: inherit; font-size: .92rem; font-weight: 600; cursor: pointer; |
| transition: transform .15s, box-shadow .25s, opacity .2s; |
| box-shadow: 0 4px 20px rgba(99,102,241,0.3); |
| } |
| .btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 28px rgba(99,102,241,0.45); } |
| .btn:active { transform: scale(.98); } |
| .btn:disabled { opacity: .55; cursor: not-allowed; } |
| .btn.loading .btn-text { opacity: .5; } |
| |
| .error-msg { |
| margin-top: 1rem; padding: .75rem 1rem; background: rgba(239,68,68,0.1); |
| border: 1px solid rgba(239,68,68,0.25); border-radius: 10px; |
| color: var(--high); font-size: .82rem; display: none; |
| } |
| .error-msg.show { display: block; } |
| |
| |
| .result { margin-top: 1.5rem; opacity: 0; transform: translateY(10px); |
| transition: opacity .4s, transform .4s; display: none; } |
| .result.show { opacity: 1; transform: translateY(0); display: block; } |
| |
| .headline { |
| border-radius: 12px; padding: 1.1rem 1.25rem; display: flex; |
| align-items: center; gap: 1rem; |
| } |
| .headline .badge { |
| font-size: .7rem; font-weight: 700; text-transform: uppercase; |
| letter-spacing: .08em; padding: .3rem .7rem; border-radius: 6px; |
| } |
| .headline .pct { font-size: 1.9rem; font-weight: 700; margin-left: auto; } |
| .headline .msg { font-size: .82rem; color: var(--text-dim); } |
| .headline .label-wrap { display: flex; flex-direction: column; gap: .3rem; } |
| |
| .result.high .headline { background: rgba(239,68,68,0.12); } |
| .result.high .badge, .result.high .pct { color: var(--high); } |
| .result.high .badge { background: var(--high); color: #fff; } |
| .result.moderate .headline { background: rgba(245,158,11,0.12); } |
| .result.moderate .badge, .result.moderate .pct { color: var(--moderate); } |
| .result.moderate .badge { background: var(--moderate); color: #1a1a1a; } |
| .result.low .headline { background: rgba(34,197,94,0.12); } |
| .result.low .badge, .result.low .pct { color: var(--low); } |
| .result.low .badge { background: var(--low); color: #1a1a1a; } |
| |
| .branches { margin-top: 1rem; display: flex; flex-direction: column; gap: .75rem; } |
| .branch { |
| background: var(--surface-2); border: 1px solid var(--border); |
| border-radius: 10px; padding: .85rem 1rem; |
| } |
| .branch h4 { font-size: .8rem; font-weight: 600; display: flex; justify-content: space-between; } |
| .branch h4 .b-pct { color: var(--text-dim); font-weight: 500; } |
| .branch .sub { font-size: .72rem; color: var(--text-dim); margin-top: .15rem; } |
| |
| .prob-row { display: flex; align-items: center; gap: .6rem; margin-top: .45rem; } |
| .prob-label { width: 130px; font-size: .68rem; color: var(--text-dim); white-space: nowrap; |
| overflow: hidden; text-overflow: ellipsis; } |
| .prob-track { flex: 1; height: 7px; background: rgba(255,255,255,0.06); border-radius: 4px; overflow: hidden; } |
| .prob-fill { height: 100%; border-radius: 4px; background: var(--accent); |
| width: 0; transition: width .6s cubic-bezier(.4,0,.2,1); } |
| .prob-fill.high { background: var(--high); } |
| .prob-fill.moderate { background: var(--moderate); } |
| .prob-fill.low { background: var(--low); } |
| .prob-value { width: 42px; text-align: right; font-size: .72rem; font-weight: 600; } |
| |
| .combined-note { font-size: .7rem; color: var(--text-dim); text-align: center; margin-top: .6rem; font-style: italic; } |
| .branch .warn { |
| margin-top: .5rem; padding: .45rem .6rem; border-radius: 7px; |
| background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); |
| color: var(--moderate); font-size: .7rem; line-height: 1.4; |
| } |
| |
| .disclaimer { |
| margin-top: 1.25rem; padding: .7rem .9rem; border-radius: 8px; |
| background: rgba(255,255,255,0.03); border: 1px solid var(--border); |
| font-size: .68rem; line-height: 1.5; color: var(--text-dim); |
| } |
| .disclaimer b { color: var(--text); } |
| |
| @media (max-width: 520px) { |
| .form-grid { grid-template-columns: 1fr; } |
| .card { padding: 2rem 1.25rem 1.5rem; } |
| .headline { flex-wrap: wrap; } |
| .headline .pct { margin-left: 0; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="card"> |
| <div class="header"> |
| <div class="icon">🫀</div> |
| <h1>Multimodal Heart Attack Risk</h1> |
| <p>Enter patient data, upload an ECG image, or both β the router picks the model(s)</p> |
| </div> |
|
|
| <form id="predictForm" autocomplete="off"> |
| |
| <div class="section-title">Patient data <span style="text-transform:none;letter-spacing:0;color:var(--text-dim)">(Framingham)</span></div> |
| <div class="form-grid"> |
| <div class="field"> |
| <label for="male">Sex</label> |
| <select id="male" name="male"> |
| <option value="" selected>β</option> |
| <option value="1">Male</option> |
| <option value="0">Female</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="age">Age</label> |
| <input id="age" name="age" type="number" min="1" max="120" placeholder="e.g. 55" /> |
| </div> |
| <div class="field"> |
| <label for="education">Education <span class="opt">Β· optional</span></label> |
| <select id="education" name="education"> |
| <option value="" selected>β</option> |
| <option value="1">1 (lowest)</option> |
| <option value="2">2</option> |
| <option value="3">3</option> |
| <option value="4">4 (highest)</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="currentSmoker">Current smoker</label> |
| <select id="currentSmoker" name="currentSmoker"> |
| <option value="" selected>β</option> |
| <option value="1">Yes</option> |
| <option value="0">No</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="cigsPerDay">Cigarettes / day <span class="opt">Β· auto-filled</span></label> |
| <input id="cigsPerDay" name="cigsPerDay" type="number" min="0" max="100" placeholder="e.g. 0" /> |
| </div> |
| <div class="field"> |
| <label for="BPMeds">On BP meds <span class="opt">Β· auto-filled</span></label> |
| <select id="BPMeds" name="BPMeds"> |
| <option value="" selected>β</option> |
| <option value="1">Yes</option> |
| <option value="0">No</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="prevalentStroke">Prior stroke</label> |
| <select id="prevalentStroke" name="prevalentStroke"> |
| <option value="" selected>β</option> |
| <option value="1">Yes</option> |
| <option value="0">No</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="prevalentHyp">Hypertension</label> |
| <select id="prevalentHyp" name="prevalentHyp"> |
| <option value="" selected>β</option> |
| <option value="1">Yes</option> |
| <option value="0">No</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="diabetes">Diabetes</label> |
| <select id="diabetes" name="diabetes"> |
| <option value="" selected>β</option> |
| <option value="1">Yes</option> |
| <option value="0">No</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label for="totChol">Total cholesterol <span class="opt">Β· auto-filled</span></label> |
| <input id="totChol" name="totChol" type="number" step="any" min="0" placeholder="mg/dL" /> |
| </div> |
| <div class="field"> |
| <label for="sysBP">Systolic BP</label> |
| <input id="sysBP" name="sysBP" type="number" step="any" min="0" placeholder="mmHg" /> |
| </div> |
| <div class="field"> |
| <label for="diaBP">Diastolic BP</label> |
| <input id="diaBP" name="diaBP" type="number" step="any" min="0" placeholder="mmHg" /> |
| </div> |
| <div class="field"> |
| <label for="BMI">BMI <span class="opt">Β· auto-filled</span></label> |
| <input id="BMI" name="BMI" type="number" step="any" min="0" placeholder="kg/mΒ²" /> |
| </div> |
| <div class="field"> |
| <label for="heartRate">Heart rate <span class="opt">Β· auto-filled</span></label> |
| <input id="heartRate" name="heartRate" type="number" step="any" min="0" placeholder="bpm" /> |
| </div> |
| <div class="field"> |
| <label for="glucose">Glucose <span class="opt">Β· auto-filled</span></label> |
| <input id="glucose" name="glucose" type="number" step="any" min="0" placeholder="mg/dL" /> |
| </div> |
| </div> |
|
|
| |
| <div class="section-title">ECG image <span style="text-transform:none;letter-spacing:0;color:var(--text-dim)">(optional)</span></div> |
| <div class="field full"> |
| <label class="drop" id="dropZone" for="ecg"> |
| <input id="ecg" name="ecg" type="file" accept="image/*" /> |
| <span id="dropText">Click or drop a 12-lead ECG image here</span> |
| <img class="preview" id="preview" alt="" hidden /> |
| <div class="filename" id="filename"></div> |
| </label> |
| </div> |
|
|
| <div class="mode" id="modeIndicator">Will run: <b>β</b></div> |
|
|
| <button type="submit" class="btn" id="submitBtn"> |
| <span class="btn-text">Assess Risk</span> |
| </button> |
| </form> |
|
|
| <div class="error-msg" id="errorMsg"></div> |
|
|
| |
| <div class="result" id="resultPanel"> |
| <div class="headline"> |
| <div class="label-wrap"> |
| <span class="badge" id="badge"></span> |
| <span class="msg" id="riskMsg"></span> |
| </div> |
| <span class="pct" id="riskPct"></span> |
| </div> |
| <div class="branches" id="branches"></div> |
| <div class="combined-note" id="combinedNote" hidden></div> |
| </div> |
|
|
| <div class="disclaimer"> |
| <b>Research / educational demo β not for clinical use.</b> The tabular model |
| (Framingham) estimates 10-year coronary-heart-disease risk; the ECG model |
| classifies the current ECG. They are trained on different populations with |
| no shared patients, so the combined score is a simple average (a transparent |
| heuristic), not a validated clinical measurement. |
| </div> |
| </div> |
|
|
| <script> |
| const form = document.getElementById('predictForm'); |
| const btn = document.getElementById('submitBtn'); |
| const errDiv = document.getElementById('errorMsg'); |
| const result = document.getElementById('resultPanel'); |
| const badge = document.getElementById('badge'); |
| const riskMsg = document.getElementById('riskMsg'); |
| const riskPct = document.getElementById('riskPct'); |
| const branchesEl = document.getElementById('branches'); |
| const combinedNote = document.getElementById('combinedNote'); |
| const modeInd = document.getElementById('modeIndicator'); |
| |
| const TABULAR_FIELDS = ['male','age','education','currentSmoker','cigsPerDay', |
| 'BPMeds','prevalentStroke','prevalentHyp','diabetes','totChol','sysBP', |
| 'diaBP','BMI','heartRate','glucose']; |
| |
| const MESSAGES = { |
| High: 'Elevated risk β recommend clinical evaluation', |
| Moderate: 'Borderline β monitor and consult a clinician', |
| Low: 'Lower risk β maintain healthy habits', |
| }; |
| |
| |
| const ecgInput = document.getElementById('ecg'); |
| const dropZone = document.getElementById('dropZone'); |
| const dropText = document.getElementById('dropText'); |
| const preview = document.getElementById('preview'); |
| const filename = document.getElementById('filename'); |
| |
| ecgInput.addEventListener('change', () => showEcg(ecgInput.files[0])); |
| ['dragover','dragenter'].forEach(ev => dropZone.addEventListener(ev, e => { |
| e.preventDefault(); dropZone.classList.add('drag'); |
| })); |
| ['dragleave','drop'].forEach(ev => dropZone.addEventListener(ev, e => { |
| e.preventDefault(); dropZone.classList.remove('drag'); |
| })); |
| dropZone.addEventListener('drop', e => { |
| if (e.dataTransfer.files.length) { ecgInput.files = e.dataTransfer.files; showEcg(ecgInput.files[0]); } |
| }); |
| |
| function showEcg(file) { |
| if (!file) return; |
| filename.textContent = file.name; |
| dropText.hidden = true; |
| preview.hidden = false; |
| preview.src = URL.createObjectURL(file); |
| updateMode(); |
| } |
| |
| |
| function hasTabular() { |
| return TABULAR_FIELDS.some(f => { |
| const el = document.getElementById(f); |
| return el && el.value.trim() !== ''; |
| }); |
| } |
| function hasEcg() { return ecgInput.files.length > 0; } |
| |
| function updateMode() { |
| const t = hasTabular(), e = hasEcg(); |
| let m = 'β'; |
| if (t && e) m = 'Both β averaged'; |
| else if (t) m = 'Tabular (Framingham)'; |
| else if (e) m = 'ECG (ResNet)'; |
| modeInd.innerHTML = 'Will run: <b>' + m + '</b>'; |
| } |
| form.addEventListener('input', updateMode); |
| updateMode(); |
| |
| |
| form.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| errDiv.classList.remove('show'); |
| result.classList.remove('show', 'high', 'moderate', 'low'); |
| |
| if (!hasTabular() && !hasEcg()) { |
| return showError('Enter at least one patient field or upload an ECG image.'); |
| } |
| |
| |
| const fd = new FormData(); |
| TABULAR_FIELDS.forEach(f => { |
| const v = document.getElementById(f).value.trim(); |
| if (v !== '') fd.append(f, v); |
| }); |
| if (hasEcg()) fd.append('ecg', ecgInput.files[0]); |
| |
| btn.classList.add('loading'); |
| btn.disabled = true; |
| try { |
| const res = await fetch('/predict', { method: 'POST', body: fd }); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.detail || 'Server error'); |
| showResult(data); |
| } catch (err) { |
| showError(err.message || 'Failed to reach the server.'); |
| } finally { |
| btn.classList.remove('loading'); |
| btn.disabled = false; |
| } |
| }); |
| |
| function showError(msg) { |
| errDiv.textContent = msg; |
| errDiv.classList.add('show'); |
| } |
| |
| |
| function pctStr(p) { return (p * 100).toFixed(1) + '%'; } |
| function pretty(c) { |
| return c.replace(/_ecg_images$/, '').replace(/_/g, ' ') |
| .replace(/\bmi\b/gi, 'MI') |
| .replace(/\b\w/g, m => m.toUpperCase()); |
| } |
| |
| function showResult(data) { |
| const level = data.risk_level; |
| result.className = 'result ' + level.toLowerCase(); |
| badge.textContent = level + ' risk'; |
| riskMsg.textContent = MESSAGES[level] || ''; |
| riskPct.textContent = pctStr(data.p_risk); |
| |
| branchesEl.innerHTML = ''; |
| const b = data.branches || {}; |
| |
| if (b.tabular) { |
| const t = b.tabular; |
| const imp = (t.imputed_fields && t.imputed_fields.length) |
| ? `KNN-imputed: ${t.imputed_fields.join(', ')}` : 'No fields imputed'; |
| branchesEl.appendChild(branchCard( |
| 'Tabular Β· Framingham 10-yr CHD', t.p_risk, t.band, |
| [['P(CHD)', t.detail['CHD']]], imp)); |
| } |
| if (b.ecg) { |
| const ec = b.ecg; |
| const rows = Object.entries(ec.detail).map(([k, v]) => [pretty(k), v]); |
| const conf = (ec.confidence != null) |
| ? ` Β· confidence ${(ec.confidence * 100).toFixed(1)}%` : ''; |
| branchesEl.appendChild(branchCard( |
| 'ECG Β· ResNet', ec.p_risk, ec.band, rows, |
| 'Predicted: ' + pretty(ec.ecg_class) + conf, ec.warning)); |
| } |
| |
| if (data.mode === 'multimodal') { |
| combinedNote.hidden = false; |
| combinedNote.textContent = |
| `Combined = average of tabular (${pctStr(b.tabular.p_risk)}) and ECG (${pctStr(b.ecg.p_risk)})`; |
| } else { |
| combinedNote.hidden = true; |
| } |
| |
| requestAnimationFrame(() => { |
| result.classList.add('show'); |
| setTimeout(() => { |
| result.querySelectorAll('.prob-fill').forEach(el => { |
| el.style.width = el.dataset.w + '%'; |
| }); |
| }, 80); |
| }); |
| } |
| |
| function branchCard(title, pRisk, band, rows, subtitle, warning) { |
| const card = document.createElement('div'); |
| card.className = 'branch'; |
| const header = `<h4>${title}<span class="b-pct">${pctStr(pRisk)} Β· ${band}</span></h4>`; |
| const sub = subtitle ? `<div class="sub">${subtitle}</div>` : ''; |
| const bars = rows.map(([label, val]) => { |
| const w = (val * 100).toFixed(1); |
| return `<div class="prob-row"> |
| <span class="prob-label" title="${label}">${label}</span> |
| <div class="prob-track"><div class="prob-fill" data-w="${w}"></div></div> |
| <span class="prob-value">${w}%</span> |
| </div>`; |
| }).join(''); |
| const warn = warning ? `<div class="warn">⚠ ${warning}</div>` : ''; |
| card.innerHTML = header + sub + bars + warn; |
| return card; |
| } |
| </script> |
| </body> |
| </html> |
|
|