neuro-eeg-analysis / src /app /utils /exportReport.ts
Aysena's picture
Deploy Neuro EEG Analysis to Hugging Face Space (CPU Basic)
fa686c4
Raw
History Blame Contribute Delete
8.07 kB
import { format } from "date-fns";
import type { Analysis, PredictionClass } from "../context/AnalysisContext";
const CLASS_FULL: Record<PredictionClass, string> = {
AD: "Alzheimer's Disease",
MCI: "Mild Cognitive Impairment",
Healthy: "No Cognitive Impairment",
};
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function imgBlock(src: string | null | undefined, alt: string): string {
if (!src) return `<p class="muted">No image available</p>`;
return `<img src="${src}" alt="${escapeHtml(alt)}" class="viz-img" />`;
}
function confidenceRows(scores?: { AD: number; MCI: number; Healthy: number }): string {
if (!scores) return "";
return (["AD", "MCI", "Healthy"] as const)
.map(
(cls) =>
`<tr><td>${cls}</td><td>${scores[cls].toFixed(1)}%</td><td>${escapeHtml(CLASS_FULL[cls])}</td></tr>`,
)
.join("");
}
function buildReportHtml(result: Analysis): string {
const dateStr = format(result.date, "MMMM d, yyyy 'at' h:mm a");
const isPatient = result.analysisMode === "patient";
const trialTable = isPatient && result.trialResults?.length
? `
<h2>Trial Breakdown</h2>
<table>
<thead>
<tr><th>Trial</th><th>File</th><th>Prediction</th><th>Confidence</th></tr>
</thead>
<tbody>
${result.trialResults
.map(
(t) =>
`<tr>
<td>${t.trialNo != null ? `t${t.trialNo}` : "—"}</td>
<td>${escapeHtml(t.fileName)}</td>
<td><span class="badge badge-${t.prediction.toLowerCase()}">${t.prediction}</span></td>
<td>${t.confidence.toFixed(1)}%</td>
</tr>`,
)
.join("")}
</tbody>
</table>`
: "";
const voteSection =
isPatient && result.voteCounts
? `
<h2>Vote Distribution</h2>
<div class="stats">
${(["AD", "MCI", "Healthy"] as const)
.map((cls) => {
const count = result.voteCounts?.[cls] ?? 0;
const total = result.trialCount ?? 0;
const pct = total > 0 ? ((count / total) * 100).toFixed(0) : "0";
return `<div class="stat ${cls === result.prediction ? "stat-win" : ""}">
<div class="stat-value">${count}/${total}</div>
<div class="stat-label">${cls}</div>
<div class="stat-pct">${pct}%</div>
</div>`;
})
.join("")}
</div>`
: "";
const vizSection =
!isPatient
? `
<h2>Visualizations</h2>
<div class="viz-grid">
<div>
<h3>Spectrogram Montage</h3>
${imgBlock(result.spectrogramImage, "Spectrogram montage")}
</div>
<div>
<h3>Scalogram Montage</h3>
${imgBlock(result.scalogramMontageImage, "Scalogram montage")}
</div>
</div>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>NeuroEEG Analysis Report</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, Segoe UI, sans-serif; color: #0f172a; max-width: 900px; margin: 0 auto; padding: 2rem; line-height: 1.5; }
h1 { font-size: 1.75rem; margin: 0 0 0.25rem; }
h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.35rem; }
h3 { font-size: 0.95rem; margin: 0 0 0.5rem; color: #475569; }
.meta { color: #64748b; font-size: 0.9rem; margin-bottom: 1.5rem; }
.prediction-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1.25rem; margin: 1rem 0; }
.prediction-label { font-size: 0.85rem; color: #64748b; margin-bottom: 0.25rem; }
.prediction-value { font-size: 1.5rem; font-weight: 700; }
.prediction-sub { font-size: 0.9rem; color: #64748b; margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-top: 0.5rem; }
th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid #e2e8f0; }
th { color: #64748b; font-weight: 600; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; font-weight: 600; }
.badge-ad { background: #fee2e2; color: #991b1b; }
.badge-mci { background: #fef3c7; color: #92400e; }
.badge-healthy { background: #dcfce7; color: #166534; }
.stats { display: flex; gap: 1rem; flex-wrap: wrap; }
.stat { flex: 1; min-width: 100px; text-align: center; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; }
.stat-win { background: #f8fafc; border-color: #94a3b8; }
.stat-value { font-size: 1.4rem; font-weight: 700; }
.stat-label { font-weight: 600; margin-top: 0.25rem; }
.stat-pct { font-size: 0.8rem; color: #64748b; }
.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.viz-img { max-width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; }
.muted { color: #94a3b8; font-size: 0.85rem; }
.disclaimer { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 1rem; font-size: 0.85rem; color: #1e3a8a; margin-top: 2rem; }
.footer { margin-top: 2rem; font-size: 0.75rem; color: #94a3b8; }
@media print { body { padding: 1rem; } }
</style>
</head>
<body>
<h1>NeuroEEG Analysis Report</h1>
<p class="meta">${escapeHtml(dateStr)} · ${isPatient ? "Patient-level analysis" : "Single trial analysis"}</p>
<div class="prediction-box">
<div class="prediction-label">${isPatient ? "Final Patient Prediction" : "Predicted Class"}</div>
<div class="prediction-value">${result.prediction}</div>
<div class="prediction-sub">${escapeHtml(CLASS_FULL[result.prediction])}</div>
${
isPatient
? `<div class="prediction-sub">Agreement: ${result.agreementRatio?.toFixed(1) ?? result.confidence.toFixed(1)}% · Patient ID: ${escapeHtml(result.patientId ?? "—")} · ${result.trialCount ?? 0} trials</div>`
: `<div class="prediction-sub">Confidence: ${result.confidence.toFixed(1)}% · File: ${escapeHtml(result.fileName)}</div>`
}
</div>
<h2>Confidence Scores</h2>
${
result.confidenceScores
? `<table>
<thead><tr><th>Class</th><th>Score</th><th>Description</th></tr></thead>
<tbody>${confidenceRows(result.confidenceScores)}</tbody>
</table>`
: `<p class="muted">Per-class confidence scores are shown at the trial level for patient analyses.</p>`
}
<p class="meta">Model: ${escapeHtml(result.model ?? "Fusion")}${result.ensembleFolds && result.ensembleFolds > 1 ? ` · ${result.ensembleFolds}-fold ensemble (soft voting)` : ""}</p>
${voteSection}
${trialTable}
${vizSection}
<div class="disclaimer">
This analysis is intended for research and clinical decision support purposes only.
Results should be interpreted by qualified medical professionals in conjunction with
other clinical assessments and patient history. This system is not a substitute for
professional medical diagnosis.
</div>
<p class="footer">Generated by NeuroEEG · Clinical Brain Signal Analysis</p>
</body>
</html>`;
}
function reportFilename(result: Analysis): string {
const stamp = format(result.date, "yyyy-MM-dd");
if (result.analysisMode === "patient" && result.patientId) {
return `neuroeeg-patient-${result.patientId}-${stamp}.html`;
}
const base = result.fileName.replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "_");
return `neuroeeg-${base}-${stamp}.html`;
}
export function downloadAnalysisReport(result: Analysis): void {
const html = buildReportHtml(result);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = reportFilename(result);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}