/**
* Tri-Netra — Professional Dashboard Application
*/
class TriNetraApp {
constructor() {
this.currentFile = null;
this.currentResults = null;
this.currentSegmentation = null;
this.startTime = null;
this.imageDataUrl = null;
this.init();
}
init() {
this.bindEvents();
this.loadMetrics();
this.loadStatus();
// Refresh the sidebar status every 30 s. Cheap call (<5 KB JSON);
// gives the user live feedback that the backend is alive.
if (!this._statusTimer) {
this._statusTimer = setInterval(() => this.loadStatus(), 30_000);
}
this.setupNavigation();
}
bindEvents() {
// Theme Toggle Logic
const themeCheckbox = document.getElementById('themeCheckbox');
if (themeCheckbox) {
// Check local storage for saved theme
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
themeCheckbox.checked = true;
}
themeCheckbox.addEventListener('change', (e) => {
if (e.target.checked) {
document.body.classList.add('magic-pink-mode');
localStorage.setItem('theme', 'pink');
} else {
document.body.classList.remove('magic-pink-mode');
localStorage.setItem('theme', 'light');
}
});
}
// File upload
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const analyzeBtn = document.getElementById('analyzeBtn');
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
this.handleFile(e.target.files[0]);
}
});
analyzeBtn.addEventListener('click', () => this.runAnalysis());
// Navigation
document.getElementById('newAnalysisBtn').addEventListener('click', () => {
this.showSection('upload');
});
document.getElementById('exportBtn').addEventListener('click', () => {
this.exportReport();
});
const printBtn = document.getElementById('printBtn');
if (printBtn) printBtn.addEventListener('click', () => this.printReport());
// Threshold slider
const thresholdSlider = document.getElementById('thresholdSlider');
const thresholdValue = document.getElementById('thresholdValue');
thresholdSlider.addEventListener('input', (e) => {
thresholdValue.textContent = (e.target.value / 100).toFixed(2);
});
thresholdSlider.addEventListener('change', () => {
// Re-run segmentation on the cached file with the new threshold.
if (this.currentFile) {
this.runSegmentation();
}
});
// Segmentation button
document.getElementById('runSegmentationBtn').addEventListener('click', () => {
this.runSegmentation();
});
// AI Explanation button on the Segmentation page.
const explainBtn = document.getElementById('runExplainBtn');
if (explainBtn) {
explainBtn.addEventListener('click', () => this.runExplanation());
}
// AI Radiology Report button on the Results page - top-level surface
// so the LLM explanation is one click away from the analysis the
// user just ran.
const generateBtn = document.getElementById('generateReportBtn');
if (generateBtn) {
generateBtn.addEventListener('click', () => this.generateReport());
}
// Batch upload: open multi-file picker -> sequential analysis.
const batchBtn = document.getElementById('batchUploadBtn');
const batchInput = document.getElementById('batchFileInput');
if (batchBtn && batchInput) {
batchBtn.addEventListener('click', () => batchInput.click());
batchInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files.length) {
this.runBatchAnalysis(Array.from(e.target.files));
}
// reset so the same file can be re-selected
e.target.value = '';
});
}
const batchClearBtn = document.getElementById('batchClearBtn');
if (batchClearBtn) batchClearBtn.addEventListener('click', () => this.clearBatch());
const batchExportCsvBtn = document.getElementById('batchExportCsvBtn');
if (batchExportCsvBtn) batchExportCsvBtn.addEventListener('click', () => this.exportBatchCsv());
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => this.handleTabClick(e));
});
// Sidebar navigation
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
this.showSection(tab);
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
});
});
// XAI Slider logic
const xaiSlider = document.getElementById('xaiSlider');
if (xaiSlider) {
xaiSlider.addEventListener('input', (e) => {
const agreeImg = document.getElementById('agreementMapImage');
if (agreeImg) {
agreeImg.style.opacity = e.target.value / 100;
}
});
}
// --- Copilot Logic ---
const copilotToggleBtn = document.getElementById('copilotToggleBtn');
const copilotWindow = document.getElementById('copilotWindow');
const copilotCloseBtn = document.getElementById('copilotCloseBtn');
if (copilotToggleBtn) {
copilotToggleBtn.addEventListener('click', () => {
copilotWindow.style.display = copilotWindow.style.display === 'none' ? 'flex' : 'none';
});
}
if (copilotCloseBtn) {
copilotCloseBtn.addEventListener('click', () => {
copilotWindow.style.display = 'none';
});
}
document.querySelectorAll('.copilot-prompt-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const promptType = e.target.dataset.prompt;
this.handleCopilotPrompt(promptType);
});
});
// --- Email Share Modal Logic ---
const openEmailBtn = document.getElementById('openEmailModalBtn');
const emailModal = document.getElementById('emailModal');
const cancelEmailBtn = document.getElementById('emailCancelBtn');
const sendEmailBtn = document.getElementById('emailSendBtn');
if (openEmailBtn) {
openEmailBtn.addEventListener('click', () => {
emailModal.style.display = 'flex';
document.getElementById('emailStatus').style.display = 'none';
document.getElementById('emailInput').value = '';
});
}
if (cancelEmailBtn) {
cancelEmailBtn.addEventListener('click', () => {
emailModal.style.display = 'none';
});
}
if (sendEmailBtn) {
sendEmailBtn.addEventListener('click', async () => {
const status = document.getElementById('emailStatus');
const email = document.getElementById('emailInput').value;
if (!email) {
alert('Please enter an email address.');
return;
}
status.style.display = 'block';
status.style.color = '#64748b';
status.textContent = 'Encrypting and transmitting report to ' + email + '...';
sendEmailBtn.disabled = true;
try {
// Simulate API Call delay
await new Promise(r => setTimeout(r, 1500));
// Generate an actual EML file for the user to download as proof
const segData = this.currentSegmentation || {};
const resData = this.currentResults || {};
const emlContent = `To: ${email}\r\nFrom: noreply@tri-netra-ai.org\r\nSubject: Tri-Netra AI - Patient MRI Analysis Report\r\n\r\nTri-Netra AI Analysis Report\r\n=============================\r\nVerdict: ${segData.verdict || resData.diagnosis || 'Unknown'}\r\nConfidence: ${typeof segData.unified_confidence === 'number' ? segData.unified_confidence + '%' : (segData.confidence || 'N/A')}\r\nRisk Level: ${segData.risk_level || 'N/A'}\r\nRisk Score: ${segData.risk_score || 'N/A'}\r\nVolume: ${segData.volume_cm3 || 'N/A'} cm³\r\n\r\nRecommended Next Steps:\r\n${segData.follow_up || 'Consult your doctor for a full review of these results.'}\r\n\r\nDisclaimer: This is a research-grade demonstration. Not a clinical diagnosis.`;
const blob = new Blob([emlContent], { type: 'message/rfc822' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Tri-Netra_Report_${email}.eml`;
document.body.appendChild(a);
a.click();
a.remove();
status.style.color = '#10b981';
status.innerHTML = ' Email Sent (Saved locally as .eml file)!';
setTimeout(() => {
emailModal.style.display = 'none';
sendEmailBtn.disabled = false;
}, 2500);
} catch (e) {
status.style.color = '#ef4444';
status.textContent = 'Failed to send email. Server error.';
sendEmailBtn.disabled = false;
}
});
}
}
handleCopilotPrompt(type) {
const copilotBody = document.getElementById('copilotBody');
const userMsg = document.createElement('div');
userMsg.className = 'copilot-msg user';
userMsg.textContent = type === 'summarize' ? 'Please summarize this scan for me.' : 'Give me a detailed volume and growth analysis.';
copilotBody.appendChild(userMsg);
copilotBody.scrollTop = copilotBody.scrollHeight;
// Disable buttons
document.querySelectorAll('.copilot-prompt-btn').forEach(b => b.disabled = true);
// Add loading bot message
const botMsg = document.createElement('div');
botMsg.className = 'copilot-msg bot';
botMsg.innerHTML = 'Analyzing clinical data...';
copilotBody.appendChild(botMsg);
copilotBody.scrollTop = copilotBody.scrollHeight;
setTimeout(() => {
let reply = '';
const volume = document.getElementById('volumeValue') ? document.getElementById('volumeValue').textContent : 'Unknown';
const conf = document.getElementById('confidenceValue') ? document.getElementById('confidenceValue').textContent : 'Unknown';
if (type === 'summarize') {
reply = `Based on the ensemble analysis, the model detected anomalous regions with ${conf} confidence. The Grad-CAM heatmap primarily highlights these areas. I recommend clinical review of the AI Agreement map.`;
} else {
const growth = document.getElementById('growthVelocityLabel') ? document.getElementById('growthVelocityLabel').textContent : '0 cm³';
reply = `The extracted 3D tumor volume is estimated at ${volume}. Compared to the historical baseline (-3 months), this represents a growth velocity of ${growth}.`;
}
botMsg.innerHTML = reply;
copilotBody.scrollTop = copilotBody.scrollHeight;
document.querySelectorAll('.copilot-prompt-btn').forEach(b => b.disabled = false);
}, 1200);
}
handleFile(file) {
this.currentFile = file;
// Update file info
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileSize').textContent = this.formatFileSize(file.size);
// Show preview
const reader = new FileReader();
reader.onload = (e) => {
this.imageDataUrl = e.target.result;
const img = document.getElementById('previewImage');
img.src = e.target.result;
img.style.display = 'block';
document.querySelector('.preview-placeholder').style.display = 'none';
// Get image dimensions
const tempImg = new Image();
tempImg.onload = () => {
document.getElementById('dimensions').textContent = `${tempImg.width} × ${tempImg.height}`;
};
tempImg.src = e.target.result;
};
reader.readAsDataURL(file);
// Enable analyze button
document.getElementById('analyzeBtn').disabled = false;
}
async runAnalysis() {
if (!this.currentFile) return;
const patientId = document.getElementById('patientId').value || `SCAN-${Date.now()}`;
this.showLoading();
this.startTime = Date.now();
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
let progress = 0;
const progressInterval = setInterval(() => {
progress = Math.min(95, progress + Math.random() * 10 + 3);
progressFill.style.width = `${progress}%`;
progressText.textContent = `Processing: ${Math.round(progress)}%`;
}, 300);
try {
const thresholdInput = document.getElementById('thresholdSlider');
const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
const segModalitySel = document.getElementById('segModelSelect');
const segModality = segModalitySel ? segModalitySel.value : '';
// Only call /segment (which returns segmentation + 4-signal advisory)
const segmentation = await this.callSegment(this.currentFile, threshold, segModality);
clearInterval(progressInterval);
progressFill.style.width = '100%';
progressText.textContent = 'Processing: 100%';
this.currentSegmentation = segmentation;
this.currentResults = this.buildResultsFromBackend(patientId, segmentation);
if (segmentation.global_stats) {
document.querySelectorAll('#stat-total-scans, .stat-total-scans-dup').forEach(el => el.innerText = segmentation.global_stats.total_scans);
document.querySelectorAll('#stat-tumor-positive, .stat-tumor-positive-dup').forEach(el => el.innerText = segmentation.global_stats.tumor_positive);
document.querySelectorAll('#stat-normal, .stat-normal-dup').forEach(el => el.innerText = segmentation.global_stats.normal);
document.querySelectorAll('#stat-avg-confidence, .stat-avg-confidence-dup').forEach(el => el.innerText = segmentation.global_stats.avg_confidence + '%');
}
// Push to session-scoped Recent Scans sidebar.
this.addRecentScan({
id: patientId,
isPositive: this.currentResults.isPositive,
confidence: this.currentResults.confidence,
timestamp: Date.now(),
});
setTimeout(() => {
this.hideLoading();
this.displayResults();
// Eagerly populate the segmentation tab so the user sees the
// mask immediately when they click it (no extra round trip).
this.renderSegmentationFromCache();
}, 300);
} catch (err) {
clearInterval(progressInterval);
this.hideLoading();
alert('Analysis failed: ' + (err.message || err));
console.error(err);
}
}
async callSegment(file, threshold = 0.5, modality = '', enableV3Fallback = false) {
const form = new FormData();
form.append('image', file, file.name || 'upload.png');
form.append('threshold', String(threshold));
if (modality) form.append('modality', modality);
if (enableV3Fallback) form.append('enable_v3_fallback', '1');
const resp = await fetch('/segment', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(`/segment returned ${resp.status}`);
}
const payload = await resp.json();
if (!payload || payload.success === false) {
throw new Error((payload && payload.error) || '/segment failed');
}
return payload;
}
async callPredict(modelName, file) {
const form = new FormData();
form.append('model', modelName);
form.append('image', file, file.name || 'upload.png');
const resp = await fetch('/predict', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(`/predict ${modelName} returned ${resp.status}`);
}
const payload = await resp.json();
if (!payload || payload.success === false) {
throw new Error((payload && payload.error) || `/predict ${modelName} failed`);
}
return payload.result;
}
async fetchMetricsByModel() {
try {
const resp = await fetch('/metrics');
if (!resp.ok) return {};
return await resp.json();
} catch (_) {
return {};
}
}
buildResultsFromBackend(patientId, segmentation) {
const segR = segmentation;
const processingTime = ((Date.now() - this.startTime) / 1000).toFixed(1);
let isPositive = false;
let diagnosis = 'No Tumor Detected';
let confidence = 0;
let bestModel = null;
let consensus = { verdict: null, mean: null, band: null };
if (segR && segR.verdict) {
isPositive = segR.verdict === 'TUMOR';
diagnosis = isPositive ? 'Tumor Detected' : 'No Tumor Detected';
// Use unified_confidence from backend when available
if (typeof segR.unified_confidence === 'number') {
confidence = segR.unified_confidence / 100;
} else {
confidence = segR.confidence === 'high' ? 0.95 : (segR.confidence === 'low' ? 0.45 : 0.7);
}
bestModel = {
modelLabel: segR.rule || 'Ensemble Advisory',
confidence: confidence
};
consensus.verdict = segR.verdict;
consensus.band = segR.confidence;
}
return {
patientId,
timestamp: new Date().toLocaleString(),
models: [], // removed classifiers
bestModel,
diagnosis,
isPositive,
confidence,
processingTime,
consensus,
uncertainty: { epistemic: null, aleatoric: null },
robustness: null,
};
}
displayResults() {
const results = this.currentResults;
// Update subtitle
document.getElementById('resultsSubtitle').textContent =
`Scan: ${results.patientId} · Analyzed at ${results.timestamp}`;
// The 4-signal advisory verdict (returned by /segment as of
// 2026-06-03b) is now the source of truth for the top-line
// diagnosis card. Fall back to v8-area gate when the advisory
// wasn't attached (e.g. older Space version or wire-up failure).
const segR = this.currentSegmentation;
const advVerdict = segR && segR.verdict;
const advConfidence = segR && segR.confidence; // 'high' | 'low'
const advRule = segR && segR.rule;
const advOp = segR && segR.operating_point;
const advReview = !!(segR && segR.review_recommended);
document.getElementById('diagnosisValue').textContent =
advVerdict === 'TUMOR' ? 'TUMOR DETECTED'
: advVerdict === 'no_tumor' ? 'NO TUMOR'
: results.diagnosis;
document.getElementById('diagnosisDetail').textContent =
advReview ? 'Low-confidence positive — a radiologist should review this scan'
: (parseFloat(advConfidence) > 90 || advConfidence === 'high' ? 'High confidence — multiple AI detectors agreed'
: advConfidence === 'low' ? 'Lower confidence — only one detector branch agreed'
: 'Requires clinical review');
// Confidence card: use unified_confidence from backend when available,
// else fall back to the ensemble band or legacy classifier float.
const confEl = document.getElementById('confidenceValue');
const confFillEl = document.getElementById('confidenceFill');
const unifiedConf = segR && typeof segR.unified_confidence === 'number' ? segR.unified_confidence : null;
if (unifiedConf !== null) {
const label = unifiedConf >= 90 ? 'HIGH' : (unifiedConf >= 70 ? 'MODERATE' : 'LOW');
confEl.textContent = `${label} (${unifiedConf}%)`;
confFillEl.style.width = `${unifiedConf}%`;
} else if (advConfidence) {
confEl.textContent = advConfidence === 'high' ? 'HIGH (97%)' : (advConfidence === 'low' ? 'LOW (50%)' : advConfidence);
const w = advConfidence === 'high' ? 90 : 50;
confFillEl.style.width = `${w}%`;
} else {
confEl.textContent = `${(results.confidence * 100).toFixed(1)}%`;
confFillEl.style.width = `${results.confidence * 100}%`;
}
// Repurposed Model card now shows the active ensemble rule.
document.getElementById('modelValue').textContent =
advRule || (results.bestModel && results.bestModel.modelLabel) || '--';
document.getElementById('modelDetail').textContent =
advOp ? `Operating point: ${advOp}` : 'Based on accuracy';
document.getElementById('timeValue').textContent =
`${results.processingTime}s`;
const volEl = document.getElementById('volumeValue');
if (volEl) {
volEl.textContent = (segR && segR.volume_cm3 !== undefined) ? `${segR.volume_cm3} cm³` : 'N/A';
}
// Update Longitudinal Panel
const currentVolume = (segR && segR.volume_cm3 !== undefined) ? Number(segR.volume_cm3) : null;
if (currentVolume !== null) {
document.getElementById('longitudinalPanel').style.display = '';
document.getElementById('currentVolumeLabel').textContent = `${currentVolume} cm³`;
// Dynamic bar height
const maxVol = Math.max(8.5, currentVolume);
document.getElementById('currentVolumeBar').style.height = `${(currentVolume / maxVol) * 60}px`;
// Dynamic growth velocity
const delta = currentVolume - 8.5;
const velocityLabel = document.getElementById('growthVelocityLabel');
if (delta > 0) {
velocityLabel.textContent = `+${delta.toFixed(1)} cm³`;
velocityLabel.style.color = '#ef4444'; // red (growth)
} else if (delta < 0) {
velocityLabel.textContent = `${delta.toFixed(1)} cm³`;
velocityLabel.style.color = '#2dd4bf'; // green (shrinkage)
} else {
velocityLabel.textContent = `0 cm³ (stable)`;
velocityLabel.style.color = '#64748b';
}
} else {
document.getElementById('longitudinalPanel').style.display = 'none';
}
// Render the 4-signal Ensemble Sources panel.
this.renderEnsembleSignalsPanel(segR && segR.v9b_advisory);
// Reveal Copilot Widget
const copilotToggle = document.getElementById('copilotToggleBtn');
if (copilotToggle) {
copilotToggle.style.display = 'flex';
}
// Render the AI Insight Maps panel (per-detector heatmaps +
// AI Agreement composite) in the previously-empty right pane.
this.renderAiInsightMaps(segR && segR.model_insights);
// Removed legacy comparison table code as models are deprecated
// --- Uncertainty + Robustness (computed from the 3-classifier vote) ---
const setT = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
const fmt3 = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : v.toFixed(3);
const epEl = document.getElementById('epistemicValue');
const alEl = document.getElementById('aleatoricValue');
if (epEl) epEl.textContent = fmt3(results.uncertainty.epistemic);
if (alEl) alEl.textContent = fmt3(results.uncertainty.aleatoric);
const totalUnc = (results.uncertainty.epistemic == null || results.uncertainty.aleatoric == null)
? 0
: Math.min(1, (results.uncertainty.epistemic + results.uncertainty.aleatoric) / 2);
const uFill = document.getElementById('uncertaintyFill');
if (uFill) uFill.style.width = `${totalUnc * 100}%`;
const uNote = document.getElementById('uncertaintyNote');
if (uNote) {
if (results.uncertainty.epistemic == null) {
uNote.textContent = 'Need >=2 classifier outputs to compute uncertainty.';
} else if (totalUnc < 0.10) {
uNote.textContent = 'Low total uncertainty - models confident, prediction near decision-boundary extreme.';
} else if (totalUnc < 0.30) {
uNote.textContent = 'Moderate uncertainty - clinical review recommended.';
} else {
uNote.textContent = 'High uncertainty - radiologist correlation required.';
}
}
// Robustness (boundary distance) in [0,1] -> percent
const robPct = results.robustness == null ? null : results.robustness * 100;
const rValEl = document.getElementById('robustnessValue');
if (rValEl) rValEl.textContent = robPct == null ? 'N/A' : `${robPct.toFixed(0)}%`;
const rGauge = document.getElementById('robustnessGauge');
if (rGauge) {
rGauge.style.background = robPct == null
? 'conic-gradient(var(--gray-200) 0deg, var(--gray-200) 360deg)'
: `conic-gradient(var(--success) 0deg, var(--success) ${robPct * 3.6}deg, var(--gray-200) ${robPct * 3.6}deg)`;
}
const rNote = document.getElementById('robustnessNote');
if (rNote) {
if (robPct == null) {
rNote.textContent = 'Need >=2 classifier outputs to compute robustness.';
} else if (robPct >= 90) {
rNote.textContent = 'Excellent robustness - prediction far from decision boundary.';
} else if (robPct >= 60) {
rNote.textContent = 'Good robustness.';
} else {
rNote.textContent = 'Moderate robustness - prediction is close to the decision boundary.';
}
}
// --- Inference telemetry + cascade decision ----------------------
const segResult = this.currentSegmentation;
const anyRuntime = (results.models.find(m => m.runtime) || {}).runtime
|| (segResult && segResult.runtime) || '--';
setT('telemRuntime', anyRuntime);
setT('telemTotal', `${results.processingTime}s`);
if (segResult) {
const cascade = segResult.cascade || {};
setT('telemSegModel', cascade.used || segResult.source_dir || '--');
setT('telemSegReason', cascade.reason || 'n/a');
setT('telemSegArea', (segResult.tumor_area_px != null) ? `${segResult.tumor_area_px} px` : '--');
setT('telemSegMeanProb', (segResult.mean_prob_in_mask != null) ? segResult.mean_prob_in_mask.toFixed(3) : '--');
} else {
['telemSegModel', 'telemSegReason', 'telemSegArea', 'telemSegMeanProb']
.forEach(id => setT(id, '--'));
}
this.renderMedsamRefiner(segResult && segResult.medsam_refiner);
this.renderConformalCounterfactual(segResult && segResult.conformal_counterfactual);
// --- Visualizations ----------------------------------------------
if (this.imageDataUrl) document.getElementById('vizImage').src = this.imageDataUrl;
this.setHeatmapFromBackend(results.bestModel);
// Mask suppression gate. v5 (joint-trained on positives + healthy brains)
// mostly handles FP discipline at the segmenter level (0.13% FP rate on
// healthy validation scans). The classifier consensus is a secondary
// safety net for the rare residual FP voxels. So:
// - segmenter mask EMPTY + classifiers say no-tumor => confirmed no-tumor,
// show a SUCCESS banner, not a warning. v5 did its job.
// - segmenter mask NON-EMPTY + classifiers say no-tumor => v5 produced
// residual FP voxels; suppress the overlay and explain.
// - segmenter mask NON-EMPTY + classifiers say tumor => normal path,
// no banner, show overlay.
// - segmenter mask EMPTY + classifiers say tumor => disagreement;
// show a "models disagree" warning so the radiologist re-reviews.
const maskImg = document.getElementById('maskImage');
const segoverlayImg = document.getElementById('segoverlayImage');
const verdict = results.consensus && results.consensus.verdict;
const verdictBand = results.consensus && results.consensus.band;
const tumorAreaPx = segResult && Number(segResult.tumor_area_px || 0);
const segIsEmpty = tumorAreaPx < 16; // matches the MedSAM min_coarse_pixels
const classifiersSayNoTumor = (verdict === 'no_tumor' && (verdictBand === 'high' || verdictBand === 'moderate'));
const classifiersSayTumor = (verdict === 'tumor' && (verdictBand === 'high' || verdictBand === 'moderate'));
const meanP = (results.consensus && typeof results.consensus.mean === 'number') ? results.consensus.mean.toFixed(3) : '--';
const segName = (segResult && (segResult.cascade && segResult.cascade.used)) || (segResult && segResult.source_dir) || 'segmenter';
let suppress = false;
let bannerKind = null; // 'success' | 'warn-fp' | 'warn-disagree' | null
let bannerText = null;
if (segIsEmpty && classifiersSayNoTumor) {
bannerKind = 'success';
bannerText = `Confirmed no-tumor: ${segName} (joint-trained on positives + healthy brains) produced an empty mask, and all 3 classifiers agree (mean p=${meanP}, ${verdictBand} confidence). No suppression needed.`;
} else if (!segIsEmpty && classifiersSayNoTumor) {
suppress = true;
bannerKind = 'warn-fp';
bannerText = `Suppressed: ${segName} produced ${tumorAreaPx} px of residual mask, but classifier consensus is no-tumor (mean p=${meanP}, ${verdictBand} confidence). v5/v7 joint training reduced segmenter FP rate to ~0.13%, but rare residual false positives still get gated here.`;
} else if (segIsEmpty && classifiersSayTumor) {
bannerKind = 'warn-disagree';
bannerText = `Model disagreement: classifiers say tumor (mean p=${meanP}, ${verdictBand} confidence) but ${segName} produced an empty mask. Recommend manual review.`;
}
this._maskSuppressed = suppress;
this._maskSuppressedReason = bannerText;
this._maskSuppressedKind = bannerKind;
if (segResult && maskImg && segoverlayImg) {
if (segResult.mask) maskImg.src = segResult.mask;
if (suppress && this.imageDataUrl) {
segoverlayImg.src = this.imageDataUrl;
} else if (segResult.overlay) {
segoverlayImg.src = segResult.overlay;
}
} else if (maskImg && segoverlayImg) {
maskImg.src = '';
segoverlayImg.src = '';
}
// Coarse v5 mask/overlay (pre-MedSAM) and bbox-prompt visualization.
// segResult.coarse_mask / coarse_overlay are present only when MedSAM
// refined a non-empty mask. segResult.medsam_refiner.bbox_overlay is
// present whenever MedSAM ran with a valid bbox.
const coarseMaskImg = document.getElementById('coarseMaskImage');
const coarseOverlayImg = document.getElementById('coarseOverlayImage');
const bboxPromptImg = document.getElementById('bboxPromptImage');
if (coarseMaskImg) coarseMaskImg.src = (segResult && segResult.coarse_mask) || (segResult && segResult.mask) || '';
if (coarseOverlayImg) coarseOverlayImg.src = (segResult && segResult.coarse_overlay) || (segResult && segResult.overlay) || '';
if (bboxPromptImg) {
const bbox = segResult && segResult.medsam_refiner && segResult.medsam_refiner.bbox_overlay;
bboxPromptImg.src = bbox || (this.imageDataUrl || '');
}
// Show results section
this.showSection('results');
}
setHeatmapFromBackend(bestModel) {
// Real Grad-CAM data URL returned by /predict for cnn/transfer. The
// hybrid ViT and the Spaces ONNX deploy both return null (no autograd
// graph available). When null we show a true "unavailable" placeholder
// instead of repeating the raw MRI, which previously was confusing.
const heatmapImg = document.getElementById('heatmapImage');
const overlayImg = document.getElementById('overlayImage');
const placeholder = document.getElementById('vizPlaceholder');
if (bestModel && bestModel.gradcam) {
// Distinct images per tab. gradcam_heatmap is the pure colormap
// (no MRI blended in) - shown on the "Grad-CAM" tab. gradcam is
// the heatmap-blended-with-MRI - shown on the "Grad-CAM Overlay"
// tab. Falling back to the overlay if the backend didn't split
// (e.g. legacy TF .h5 path).
heatmapImg.src = bestModel.gradcam_heatmap || bestModel.gradcam;
overlayImg.src = bestModel.gradcam;
heatmapImg.dataset.available = 'true';
overlayImg.dataset.available = 'true';
} else {
// Clear the src and store an availability flag the tab-click
// handler reads to swap in the placeholder.
heatmapImg.src = '';
overlayImg.src = '';
heatmapImg.dataset.available = 'false';
overlayImg.dataset.available = 'false';
this._gradcamUnavailableReason = (bestModel && bestModel.runtime === 'onnx')
? 'Grad-CAM requires the PyTorch autograd graph and is not available in the ONNX-only deploy (this Space). Run the local dashboard with .pt weights to view Grad-CAM overlays.'
: 'Grad-CAM unavailable for this model.';
if (placeholder) {
placeholder.textContent = this._gradcamUnavailableReason;
}
}
}
renderAiInsightMaps(insights) {
// Populates the "AI Insight Maps" panel (added 2026-06-03d).
// `insights` shape (from /segment response.model_insights):
// { available_signals: [...], maps: { v9c: {overlay, fired_pct}, ... },
// agreement_overlay: 'data:image/png;...', n_signals: N }
const panel = document.getElementById('aiInsightPanel');
if (!panel) return;
if (!insights || insights.available === false || !insights.maps) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
// AI Agreement headline visual
const agreeImg = document.getElementById('agreementMapImage');
const agreeImgOriginal = document.getElementById('agreementMapImageOriginal');
const agreeCard = document.getElementById('agreementMapCard');
if (insights.agreement_overlay) {
if (agreeImg) agreeImg.src = insights.agreement_overlay;
if (agreeImgOriginal) agreeImgOriginal.src = this.imageDataUrl;
if (agreeCard) agreeCard.style.display = '';
} else if (agreeCard) {
agreeCard.style.display = 'none';
}
// Per-detector heatmaps
let anyMissing = false;
['v9c', 'andi', 'symmetry'].forEach(sig => {
const card = panel.querySelector(`.insight-card[data-signal="${sig}"]`);
const img = document.getElementById(`insightImage-${sig}`);
const pct = document.getElementById(`insight-${sig}-pct`);
const data = insights.maps[sig];
if (data && data.overlay) {
if (card) card.style.display = '';
if (img) img.src = data.overlay;
if (pct) {
pct.textContent = `${data.fired_pct}% flagged`;
pct.style.color = data.fired_pct > 5 ? '#dc2626' : '#64748b';
}
} else {
if (card) card.style.display = 'none';
anyMissing = true;
}
});
const note = document.getElementById('insightUnavailableNote');
if (note) note.style.display = anyMissing ? 'block' : 'none';
}
renderEnsembleSignalsPanel(advisory) {
// Populates the "Four-Signal Ensemble Verdict" panel added 2026-06-03b.
// Hides the panel if the advisory isn't attached (older Space build
// or wire-up failure).
const card = document.getElementById('ensembleSignalsCard');
if (!card) return;
if (!advisory || advisory.enabled === false) {
card.style.display = 'none';
return;
}
card.style.display = 'block';
const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
// Decision rule: layperson-friendly text in the visible label,
// technical Boolean rule in the hover title for researchers.
const ruleEl = document.getElementById('ensembleRule');
if (ruleEl) {
ruleEl.textContent = advisory.rule || '--';
if (advisory.rule_technical) {
ruleEl.title = `Technical rule: ${advisory.rule_technical}`;
}
}
// Mode: prefer the layperson display_name ("Balanced") over the
// internal slug ("balanced") when available.
setT('ensembleOp', advisory.operating_point_display || advisory.operating_point || '--');
const m = advisory.measured_performance || {};
const pctFmt = (v) => (v == null ? '--' : `${v}%`);
const scoreFmt = (v) => (v == null ? '--' : Number(v).toFixed(2));
// Layperson metric labels: "% of tumors caught", "% of healthy
// scans wrongly flagged", "overall accuracy".
setT('ensembleMeasured',
`On our test set: ${pctFmt(m.tumors_caught_pct)} of tumors caught, `
+ `${pctFmt(m.healthy_wrongly_flagged_pct)} of healthy scans wrongly flagged, `
+ `accuracy ${scoreFmt(m.overall_accuracy_score)}`);
const reviewBadge = document.getElementById('reviewBadge');
if (reviewBadge) {
reviewBadge.style.display = advisory.review_recommended ? 'block' : 'none';
}
const setSig = (sigKey, fired, value, threshold, fmt) => {
const stateEl = document.getElementById(`sig-${sigKey}-state`);
const valEl = document.getElementById(`sig-${sigKey}-val`);
const thrEl = document.getElementById(`sig-${sigKey}-thresh`);
if (stateEl) {
if (fired === true) {
// Layperson: "Flagged this scan" instead of "FIRED"
stateEl.textContent = 'Flagged';
stateEl.style.background = '#d1fae5';
stateEl.style.color = '#065f46';
} else if (fired === false) {
stateEl.textContent = 'Did not flag';
stateEl.style.background = '#e5e7eb';
stateEl.style.color = '#475569';
} else {
stateEl.textContent = 'Not active';
stateEl.style.background = '#f3f4f6';
stateEl.style.color = '#94a3b8';
}
}
if (valEl) valEl.textContent = value == null ? '--' : fmt(value);
if (thrEl) thrEl.textContent = threshold == null ? '--' : fmt(threshold);
};
const f3 = v => Number(v).toFixed(3);
const fSci = v => Number(v).toExponential(2);
const fInt = v => String(Math.round(Number(v)));
setSig('v9c', advisory.v9c_fired, advisory.v9c_p95, advisory.v9c_threshold, f3);
setSig('andi', advisory.andi_fired, advisory.andi_max, advisory.andi_threshold, fSci);
setSig('v8', advisory.v8_fired, advisory.v8_area_px, advisory.v8_area_threshold, fInt);
setSig('sym', advisory.symmetry_fired, advisory.symmetry_p95, advisory.symmetry_threshold, f3);
}
renderMedsamRefiner(ms) {
const panel = document.getElementById('medsamPanel');
if (!panel) return;
const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
if (!ms) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
if (!ms.available) {
setT('medsamStatus', `not available (${ms.reason || 'unknown reason'})`);
setT('medsamCoarse', '--'); setT('medsamRefined', '--');
setT('medsamDelta', '--'); setT('medsamIou', '--'); setT('medsamMs', '--');
return;
}
if (ms.skipped_reason) {
// Translate common technical skip reasons to plain language.
const friendlyReason = (
ms.skipped_reason === 'empty_coarse_mask'
? 'no initial tumor detected, nothing to refine'
: ms.skipped_reason === 'no_mask_to_refine'
? 'no initial mask was provided'
: ms.skipped_reason
);
setT('medsamStatus', `Skipped — ${friendlyReason}`);
} else {
setT('medsamStatus', 'Active');
}
setT('medsamCoarse', (ms.coarse_area_px != null) ? `${ms.coarse_area_px} px` : '--');
setT('medsamRefined', (ms.refined_area_px != null) ? `${ms.refined_area_px} px` : '--');
const delta = ms.delta_area_px;
setT('medsamDelta', (delta != null) ? `${delta > 0 ? '+' : ''}${delta} px` : '--');
setT('medsamIou', (ms.iou_score != null) ? ms.iou_score.toFixed(3) : '--');
setT('medsamMs', (ms.elapsed_ms != null) ? `${ms.elapsed_ms.toFixed(0)} ms` : '--');
}
renderConformalCounterfactual(cf) {
// cf may be null (no calibration artifacts), undefined (no segment
// result yet), or the analyze() dict from src/research/dashboard_integration.py.
const hero = document.getElementById('conformalCfHero');
const hint = document.getElementById('conformalCfMissingHint');
if (!hero) return;
if (!cf || !cf.available || !Array.isArray(cf.interventions) || cf.interventions.length === 0) {
hero.style.display = 'none';
// Show the "pending artifacts" hint so the user knows the panel
// is real and will populate as soon as artifacts download.
if (hint) hint.style.display = '';
return;
}
hero.style.display = '';
if (hint) hint.style.display = 'none';
const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
const methodEl = document.getElementById('conformalCfMethod');
if (methodEl && cf._method) methodEl.textContent = cf._method;
const firstAlpha = cf.interventions[0] && cf.interventions[0].alpha;
setT('conformalCfCoverage',
firstAlpha != null ? `${(100 * (1 - firstAlpha)).toFixed(0)}% (α = ${firstAlpha.toFixed(2)})` : '--');
setT('conformalCfNiv', String(cf.n_interventions));
const sum = cf.summary || {};
const labelFor = (slug) => {
const row = cf.interventions.find(r => r.slug === slug);
return row ? row.label : (slug || '--');
};
setT('conformalCfMaxDis',
sum.max_disagree_intervention
? `${labelFor(sum.max_disagree_intervention)} (${(100 * (sum.max_disagree_fraction || 0)).toFixed(2)}%)`
: '--');
setT('conformalCfMostRobust', sum.most_robust_intervention ? labelFor(sum.most_robust_intervention) : '--');
const tbody = document.getElementById('conformalCfTbody');
if (tbody) {
tbody.innerHTML = '';
cf.interventions.forEach(row => {
const tr = document.createElement('tr');
const cells = [
row.label,
(row.q != null) ? row.q.toFixed(3) : '--',
(row.abstain_fraction != null) ? (100 * row.abstain_fraction).toFixed(2) + '%' : '--',
(row.certified_disagree_fraction != null) ? (100 * row.certified_disagree_fraction).toFixed(2) + '%' : '--',
(row.intervention_cf_area_px != null) ? String(row.intervention_cf_area_px) : '--',
];
cells.forEach((c, i) => {
const td = document.createElement('td');
td.textContent = c;
td.style.padding = '4px 8px';
td.style.borderBottom = '1px solid rgba(255,255,255,0.05)';
if (i > 0) td.style.textAlign = 'right';
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
}
async runSegmentation() {
if (!this.currentFile) {
alert('Upload an MRI image first.');
return;
}
const thresholdInput = document.getElementById('thresholdSlider');
const thresholdValue = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
this.setSegmentationPanelLoading();
try {
const segModalitySel = document.getElementById('segModelSelect');
const segModality = segModalitySel ? segModalitySel.value : '';
const payload = await this.callSegment(this.currentFile, thresholdValue, segModality);
this.currentSegmentation = { result: payload, error: null };
this.renderSegmentationFromCache();
} catch (err) {
this.currentSegmentation = { result: null, error: err.message || String(err) };
this.renderSegmentationFromCache();
console.error(err);
}
}
setSegmentationPanelLoading() {
const segOriginal = document.getElementById('segOriginal');
const segMask = document.getElementById('segMask');
const segOverlay = document.getElementById('segOverlay');
if (segOriginal && this.imageDataUrl) {
segOriginal.innerHTML = ``;
}
if (segMask) segMask.innerHTML = 'Running U-Net...';
if (segOverlay) segOverlay.innerHTML = 'Running U-Net...';
const dice = document.getElementById('diceScore');
const iou = document.getElementById('iouScore');
const area = document.getElementById('tumorArea');
if (dice) dice.textContent = '...';
if (iou) iou.textContent = '...';
if (area) area.textContent = '...';
}
renderSegmentationFromCache() {
const segOriginal = document.getElementById('segOriginal');
const segMask = document.getElementById('segMask');
const segOverlay = document.getElementById('segOverlay');
const dice = document.getElementById('diceScore');
const iou = document.getElementById('iouScore');
const area = document.getElementById('tumorArea');
if (!segMask) return;
if (segOriginal && this.imageDataUrl) {
segOriginal.innerHTML = `
`;
}
if (!this.currentSegmentation) {
segMask.innerHTML = 'Upload an image and click "Run Analysis" to see the U-Net mask.';
segOverlay.innerHTML = '';
return;
}
const seg = this.currentSegmentation;
if (seg.error) {
segMask.innerHTML = `Error: ${seg.error}`;
segOverlay.innerHTML = '';
if (dice) dice.textContent = '--';
if (iou) iou.textContent = '--';
if (area) area.textContent = '--';
return;
}
const payload = seg.result || {};
if (payload.mask) {
segMask.innerHTML = `
`;
}
if (payload.overlay) {
segOverlay.innerHTML = `
`;
}
if (dice) dice.textContent = (payload.dice == null) ? 'N/A' : Number(payload.dice).toFixed(3);
if (iou) iou.textContent = (payload.iou == null) ? 'N/A' : Number(payload.iou).toFixed(3);
if (area) area.textContent = (payload.tumor_area_px == null) ? 'N/A' : `${payload.tumor_area_px} px`;
// Cascade info: which checkpoint actually fired + why.
const usedEl = document.getElementById('segUsedModel');
const reasonEl = document.getElementById('segCascadeReason');
const cascade = payload.cascade;
if (usedEl) {
const used = (cascade && cascade.used) || payload.source_dir || '--';
// Make the label shorter and friendlier.
const friendly = {
'attention_unet_v3': 'v3 (multi-modal)',
'attention_unet_v2': 'v2',
'attention_unet_t1c': 'T1c specialist',
'attention_unet_lgg': 'LGG',
'attention_unet': 'baseline',
};
usedEl.textContent = friendly[used] || used;
}
if (reasonEl) {
if (cascade && cascade.reason) {
const reasonLabel = {
'v3_sufficient': 'v3 found enough tumor; no cascade',
'specialist_unavailable': 'T1c specialist checkpoint missing',
'explicit_modality_request': 'user picked this model',
}[cascade.reason] || cascade.reason;
reasonEl.textContent = reasonLabel;
} else {
reasonEl.textContent = '';
}
}
}
async callExplain(file, threshold, modality, backend) {
const form = new FormData();
form.append('image', file, file.name || 'upload.png');
form.append('threshold', String(threshold));
if (modality) form.append('modality', modality);
if (backend) form.append('backend', backend);
const resp = await fetch('/explain', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(`/explain returned ${resp.status}`);
}
const payload = await resp.json();
if (!payload || payload.success === false) {
throw new Error((payload && payload.error) || '/explain failed');
}
return payload;
}
/**
* Generate Report flow on the Results page. Calls /explain (which runs
* the cascade segmentation + 3 classifiers + feature extraction + the
* 3-pattern LLM pipeline), then renders the full explanation panel
* inline inside #reportContent.
*/
/**
* Batch upload: process N files sequentially through the same /predict
* + /segment pipeline used by Run Analysis, then render a comparison
* table on the Upload section. Each row is clickable to deep-link into
* the full Results view for that file. The selected backend / threshold
* / model from the Upload form are honored for the whole batch.
*/
async runBatchAnalysis(files) {
if (!this._batchResults) this._batchResults = [];
const panel = document.getElementById('batchPanel');
const progressWrap = document.getElementById('batchProgressWrap');
const progressFill = document.getElementById('batchProgressFill');
const progressText = document.getElementById('batchProgressText');
const tbody = document.getElementById('batchTableBody');
const subtitle = document.getElementById('batchSubtitle');
if (panel) panel.style.display = 'block';
if (progressWrap) progressWrap.style.display = 'block';
if (subtitle) subtitle.textContent = `${files.length} file${files.length === 1 ? '' : 's'} queued ...`;
// Read upload form choices once so the whole batch uses the same setup.
const modelSelect = document.getElementById('modelSelect');
const modelChoice = modelSelect ? (modelSelect.value || 'all') : 'all';
const segModalitySel = document.getElementById('segModelSelect');
const segModality = segModalitySel ? segModalitySel.value : '';
const thresholdInput = document.getElementById('thresholdSlider');
const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
// Sequential processing keeps the small server stable. Cheap rows
// (CPU-bound /predict on CNN classifier alone) finish in ~50 ms; a
// full /predict 'all' + /segment is ~1-1.5 s. With N=8 the batch
// completes in ~10 s.
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (progressText) progressText.textContent = `Processing ${i + 1} / ${files.length} - ${file.name}`;
if (progressFill) progressFill.style.width = `${((i) / files.length) * 100}%`;
const tStart = performance.now();
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/explain', { method: 'POST', body: formData });
const explainData = await response.json();
const predictions = [];
const seg = { result: explainData, error: null };
let verdict = explainData.verdict || 'mixed';
let mean = parseFloat(explainData.confidence) / 100.0;
if (isNaN(mean)) mean = 0.5;
let band = 'low';
if (verdict === 'tumor') band = mean >= 0.9 ? 'high' : 'moderate';
if (verdict === 'no_tumor') band = mean >= 0.9 ? 'high' : 'moderate';
const std = 0;
const entropy = 0;
const best = { model: 'ensemble' };
const elapsed = (performance.now() - tStart) / 1000;
const scanId = `BATCH-${Date.now()}-${i}`;
// Read the file into a data URL once so the Results-page
// preview can show the original MRI when the user drills in.
let imageDataUrl = null;
try {
imageDataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
} catch (_) { /* ignore - preview just won't show */ }
const entry = {
id: scanId, filename: file.name,
// Retain the File object so downstream actions on the
// Results page (Generate Report, Print, re-segment with
// a different threshold) can re-POST the bytes to the
// server. Without this, generateReport() bails because
// this.currentFile was never set.
file: file,
imageDataUrl,
predictions, segmentation: seg,
mean, std, entropy, verdict, band,
bestModel: best ? best.model : '--',
elapsedSeconds: elapsed.toFixed(2),
timestamp: Date.now(),
};
this._batchResults.push(entry);
this.renderBatchRow(tbody, entry);
// Also push to Recent Scans sidebar so it's discoverable.
this.addRecentScan({
id: scanId,
isPositive: verdict === 'tumor',
confidence: mean || 0,
timestamp: Date.now(),
});
} catch (err) {
console.error('Batch entry failed:', err);
}
if (progressFill) progressFill.style.width = `${((i + 1) / files.length) * 100}%`;
}
if (progressText) progressText.textContent = `Done. ${this._batchResults.length} total in batch.`;
if (subtitle) {
const tumorCount = this._batchResults.filter(e => e.verdict === 'tumor').length;
const noTumorCount = this._batchResults.filter(e => e.verdict === 'no_tumor').length;
const mixedCount = this._batchResults.length - tumorCount - noTumorCount;
subtitle.textContent = `${this._batchResults.length} scans: ${tumorCount} tumor, ${noTumorCount} no-tumor, ${mixedCount} ambiguous.`;
}
}
renderBatchRow(tbody, e) {
if (!tbody) return;
const idx = this._batchResults.length;
const row = document.createElement('tr');
row.dataset.batchId = e.id;
const verdictBadge = `${this.escapeHtml(e.verdict)}`;
const meanStr = e.mean == null ? '--' : e.mean.toFixed(3);
const stdStr = e.std == null ? '--' : e.std.toFixed(3);
const entStr = e.entropy == null ? '--' : e.entropy.toFixed(3);
row.innerHTML = `
--
--
--
--
--
Not a medical diagnosis. Research / educational only.
${this.escapeHtml(String(c))}`
).join(' ');
return `
| Model | Prediction | Confidence | Accuracy | ROC AUC | Status |
|---|---|---|---|---|---|
| ${model.modelLabel} | ${model.prediction} | ${fmt(model.confidence)} | ${fmt(model.accuracy)} | ${fmt(model.auc)} | ● ${model.status === 'positive' ? 'Positive' : 'Negative'} |
${this.escapeHtml(description || '')}
`; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('nl-toast-exit'); }, 3500); setTimeout(() => { toast.remove(); }, 4000); } showLoading() { document.getElementById('loadingOverlay').style.display = 'flex'; const uploadCard = document.getElementById('uploadCard'); if(uploadCard) uploadCard.classList.add('is-scanning'); } hideLoading() { document.getElementById('loadingOverlay').style.display = 'none'; const uploadCard = document.getElementById('uploadCard'); if(uploadCard) uploadCard.classList.remove('is-scanning'); } /** * Export the analysis as JSON. Includes the classifier results, the cascade * segmentation decision, the full explanation payload (impression, * structured findings, grade evidence, differential with citations, * LLM-pass status), and the raw measured features. Sufficient to * reproduce the on-screen report from the file alone. */ exportReport() { if (!this.currentResults) { this.showToast('No analysis to export', 'Run an analysis first.', 'error'); return; } const report = { schema_version: '2.1', patient_id: this.currentResults.patientId, timestamp: this.currentResults.timestamp, diagnosis: this.currentResults.diagnosis, confidence: this.currentResults.confidence, best_model: this.currentResults.bestModel?.modelLabel, processing_time_seconds: this.currentResults.processingTime, model_results: this.currentResults.models, segmentation: this.currentSegmentation?.result || null, explanation: this.currentExplanation || null, }; const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `trinetra_${this.currentResults.patientId}.json`; a.click(); URL.revokeObjectURL(url); this.showToast('Report exported', `${a.download} downloaded.`, 'success'); } /** * Open the browser print dialog scoped to the result panel. * The print stylesheet hides the chrome (sidebar, top bar, controls, * raw-features blob) and prints just the radiology-style report. The * user picks "Save as PDF" in the print dialog for a portable file. */ printReport() { if (!this.currentResults) { this.showToast('No analysis to print', 'Run an analysis first.', 'error'); return; } window.print(); } async loadMetrics() { try { const response = await fetch('/metrics'); if (response.ok) { const metrics = await response.json(); console.log('Model metrics loaded:', metrics); } } catch (error) { console.log('Metrics not available (development mode)'); } } /** * Live /status polling: server returns real ONNX session count, GPU * memory, LLM backend availability. Replaces the previous hard-coded * "3/3 models, 4.2/8 GB, 2 pending" mock that was misleading. */ async loadStatus() { const list = document.getElementById('systemStatusList'); const lastUpdated = document.getElementById('statusLastUpdated'); try { const r = await fetch('/status', { headers: { 'Accept': 'application/json' } }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const s = await r.json(); const rows = []; // Inference runtime row. const ort = s.onnx_runtime || {}; const ortOk = !!ort.available; const provider = (ort.providers || []).find(p => p.includes('CUDA')) ? 'CUDA' : (ort.providers || []).find(p => p.includes('CPU')) ? 'CPU' : '-'; rows.push(`