Shahd1sayed's picture
Deploy v2: multimodal ensemble router (Framingham tabular + ECG ResNet)
0f12291 verified
Raw
History Blame Contribute Delete
23.1 kB
<!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); }
/* ECG drop zone */
.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 indicator */
.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 */
.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">&#x1FAC0;</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">
<!-- ── Tabular (Framingham) ─────────────────────────────────────── -->
<div class="section-title">Patient data &nbsp;<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>
<!-- ── ECG image ─────────────────────────────────────────────────── -->
<div class="section-title">ECG image &nbsp;<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>
<!-- Result -->
<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',
};
// ── ECG upload preview ────────────────────────────────────────────
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();
}
// ── Mode indicator ────────────────────────────────────────────────
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();
// ── Submit ────────────────────────────────────────────────────────
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.');
}
// Build multipart payload: only non-empty fields (blanks β†’ KNN-imputed server-side)
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');
}
// ── Render result ─────────────────────────────────────────────────
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">&#9888; ${warning}</div>` : '';
card.innerHTML = header + sub + bars + warn;
return card;
}
</script>
</body>
</html>