adityasync's picture
feat: Implement dark mode functionality and refine various frontend layout styles.
f7864d7
const STORAGE_KEY = "oncovision.history";
const routeViews = {
landing: document.getElementById("landingPage"),
upload: document.getElementById("uploadPage"),
analysis: document.getElementById("analysisPage"),
results: document.getElementById("resultsPage"),
history: document.getElementById("historyPage"),
technical: document.getElementById("technicalPage"),
};
const state = {
history: loadHistory(),
selectedFiles: [],
currentScan: null,
analysisTimer: null,
analysisStepTimer: null,
analysisResultById: new Map(),
activeModal: null,
};
const uploadInput = document.getElementById("ctScan");
const uploadZone = document.getElementById("uploadZone");
const uploadForm = document.getElementById("uploadForm");
const selectedFilePanel = document.getElementById("selectedFilePanel");
const selectedFileName = document.getElementById("selectedFileName");
const selectedFileMeta = document.getElementById("selectedFileMeta");
const fileFormatValue = document.getElementById("fileFormatValue");
const fileCountValue = document.getElementById("fileCountValue");
const fileSizeValue = document.getElementById("fileSizeValue");
const removeFileButton = document.getElementById("removeFileButton");
const analyzeButton = document.getElementById("analyzeButton");
const sampleButton = document.getElementById("sampleButton");
const uploadErrorBanner = document.getElementById("uploadErrorBanner");
const recentUploads = document.getElementById("recentUploads");
const analysisDetail = document.getElementById("analysisDetail");
const analysisProgressBar = document.getElementById("analysisProgressBar");
const analysisProgressLabel = document.getElementById("analysisProgressLabel");
const analysisSteps = Array.from(document.querySelectorAll(".analysis-steps span"));
const riskBanner = document.getElementById("riskBanner");
const riskIcon = document.getElementById("riskIcon");
const riskLabel = document.getElementById("riskLabel");
const riskSummary = document.getElementById("riskSummary");
const riskRecommendation = document.getElementById("riskRecommendation");
const noduleCount = document.getElementById("noduleCount");
const analysisDate = document.getElementById("analysisDate");
const analysisStatus = document.getElementById("analysisStatus");
const resultsMeta = document.getElementById("resultsMeta");
const scanFileName = document.getElementById("scanFileName");
const scanFileSize = document.getElementById("scanFileSize");
const scanFileFormat = document.getElementById("scanFileFormat");
const recommendationText = document.getElementById("recommendationText");
const timingGrid = document.getElementById("timingGrid");
const scanImage = document.getElementById("scanImage");
const scanPlaceholder = document.getElementById("scanPlaceholder");
const nodulesList = document.getElementById("nodulesList");
const emptyState = document.getElementById("emptyState");
const noduleFilter = document.getElementById("noduleFilter");
const resultsBreadcrumb = document.getElementById("resultsBreadcrumb");
const historySubtitle = document.getElementById("historySubtitle");
const historySearch = document.getElementById("historySearch");
const historyFilter = document.getElementById("historyFilter");
const historySort = document.getElementById("historySort");
const historyGroups = document.getElementById("historyGroups");
const noduleCardTemplate = document.getElementById("noduleCardTemplate");
const historyCardTemplate = document.getElementById("historyCardTemplate");
const exportImageButton = document.getElementById("exportImageButton");
const exportPdfButton = document.getElementById("exportPdfButton");
const floatingExportButton = document.getElementById("floatingExportButton");
const shareButton = document.getElementById("shareButton");
const saveScanButton = document.getElementById("saveScanButton");
const deleteScanButton = document.getElementById("deleteScanButton");
const previousScanButton = document.getElementById("previousScanButton");
const nextScanButton = document.getElementById("nextScanButton");
const mobileNavToggle = document.getElementById("mobileNavToggle");
const mobileNav = document.getElementById("mobileNav");
const topbar = document.getElementById("topbar");
const modalBackdrop = document.getElementById("modalBackdrop");
const settingsModal = document.getElementById("settingsModal");
const helpModal = document.getElementById("helpModal");
const exportModal = document.getElementById("exportModal");
const shareModal = document.getElementById("shareModal");
const technicalTabs = Array.from(document.querySelectorAll("[data-tech-tab]"));
const previewExportButton = document.getElementById("previewExportButton");
const patientNameInput = document.getElementById("patientNameInput");
const patientMrnInput = document.getElementById("patientMrnInput");
const patientDobInput = document.getElementById("patientDobInput");
const physicianNotesInput = document.getElementById("physicianNotesInput");
const confirmShareButton = document.getElementById("confirmShareButton");
const shareEmailInput = document.getElementById("shareEmailInput");
const shareMessageInput = document.getElementById("shareMessageInput");
const themeRadios = Array.from(document.querySelectorAll("input[name='theme']"));
const THEME_KEY = "oncovision.theme";
const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
const analysisMessages = [
"Processing 324 slices",
"Detecting nodules",
"Calculating risk",
];
wireNavigation();
wireUpload();
wireActions();
wireModals();
wireTechnicalTabs();
wireThemeSelector();
renderRecentUploads();
renderHistoryPage();
updateNavState();
handleRoute();
window.addEventListener("popstate", handleRoute);
window.addEventListener("scroll", () => {
topbar.classList.toggle("topbar--solid", window.scrollY > 24);
});
function wireNavigation() {
document.querySelectorAll("[data-link]").forEach((link) => {
link.addEventListener("click", (event) => {
const href = link.getAttribute("href");
if (!href || href.startsWith("http")) {
return;
}
event.preventDefault();
mobileNav.hidden = true;
mobileNav.classList.remove("is-open");
navigate(href);
});
});
mobileNavToggle.addEventListener("click", () => {
const willOpen = mobileNav.hidden;
mobileNav.hidden = !willOpen;
mobileNav.classList.toggle("is-open", willOpen);
});
}
function wireUpload() {
uploadInput.addEventListener("change", () => {
state.selectedFiles = Array.from(uploadInput.files || []);
syncSelectedFiles();
});
["dragenter", "dragover"].forEach((eventName) => {
uploadZone.addEventListener(eventName, (event) => {
event.preventDefault();
uploadZone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
uploadZone.addEventListener(eventName, (event) => {
event.preventDefault();
uploadZone.classList.remove("is-dragging");
});
});
uploadZone.addEventListener("drop", (event) => {
const files = Array.from(event.dataTransfer?.files || []);
if (!files.length) {
return;
}
state.selectedFiles = files;
uploadInput.files = event.dataTransfer.files;
syncSelectedFiles();
});
removeFileButton.addEventListener("click", () => {
state.selectedFiles = [];
uploadInput.value = "";
syncSelectedFiles();
});
uploadForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedFiles.length) {
showUploadError("Select at least one scan file before starting analysis.");
return;
}
hideUploadError();
const scanId = createScanId();
const fileSummary = createFileSummary(state.selectedFiles);
const scan = {
id: scanId,
fileSummary,
createdAt: new Date().toISOString(),
status: "Analyzing",
};
state.currentScan = scan;
navigate(`/analysis/${scanId}`);
startAnalysis(scan, async () => {
try {
const result = await analyzeSelectedFiles();
finalizeAnalysis(scan, result);
} catch (error) {
stopAnalysisAnimation();
navigate("/upload");
showUploadError(error.message || "Analysis could not be completed.");
}
});
});
sampleButton.addEventListener("click", () => {
const scanId = createScanId();
const sample = buildSampleScan(scanId);
state.currentScan = sample;
navigate(`/analysis/${scanId}`);
startAnalysis(sample, () => {
window.setTimeout(() => {
finalizeAnalysis(sample, sample.result);
}, 2800);
});
});
}
function wireActions() {
noduleFilter.addEventListener("change", () => {
if (state.currentScan?.result) {
renderResultsPage(state.currentScan);
}
});
historySearch.addEventListener("input", renderHistoryPage);
historyFilter.addEventListener("change", renderHistoryPage);
historySort.addEventListener("change", renderHistoryPage);
exportImageButton.addEventListener("click", exportCurrentImage);
exportPdfButton.addEventListener("click", () => openModal("export"));
floatingExportButton.addEventListener("click", () => openModal("export"));
shareButton.addEventListener("click", () => openModal("share"));
saveScanButton.addEventListener("click", () => {
if (state.currentScan) {
toastLikeAlert("Scan saved to local history.");
}
});
deleteScanButton.addEventListener("click", deleteCurrentScan);
previousScanButton.addEventListener("click", () => navigateHistoryNeighbor(-1));
nextScanButton.addEventListener("click", () => navigateHistoryNeighbor(1));
}
function wireModals() {
document.getElementById("helpButton").addEventListener("click", () => openModal("help"));
document.getElementById("footerHelpButton").addEventListener("click", () => openModal("help"));
document.getElementById("footerSettingsButton").addEventListener("click", () => openModal("settings"));
previewExportButton.addEventListener("click", () => generatePdfReport(true));
document.getElementById("generateExportButton").addEventListener("click", () => {
generatePdfReport(false);
});
confirmShareButton.addEventListener("click", confirmShare);
modalBackdrop.addEventListener("click", (event) => {
if (event.target === modalBackdrop) {
openModal(null);
}
});
document.querySelectorAll("[data-close-modal]").forEach((button) => {
button.addEventListener("click", () => openModal(null));
});
}
function wireThemeSelector() {
const stored = localStorage.getItem(THEME_KEY) || "auto";
themeRadios.forEach((input) => {
input.checked = input.value === stored;
input.addEventListener("change", () => {
localStorage.setItem(THEME_KEY, input.value);
applyTheme(input.value);
});
});
applyTheme(stored);
const handleSystemThemeChange = () => {
if (localStorage.getItem(THEME_KEY) === "auto") {
applyTheme("auto");
}
};
if (typeof prefersDarkScheme.addEventListener === "function") {
prefersDarkScheme.addEventListener("change", handleSystemThemeChange);
} else if (typeof prefersDarkScheme.addListener === "function") {
prefersDarkScheme.addListener(handleSystemThemeChange);
}
}
function applyTheme(preference) {
const resolved =
preference === "dark"
? "dark"
: preference === "light"
? "light"
: prefersDarkScheme.matches
? "dark"
: "light";
document.body.classList.toggle("theme-dark", resolved === "dark");
document.body.classList.toggle("theme-light", resolved === "light");
document.documentElement.dataset.theme = preference;
}
function wireTechnicalTabs() {
technicalTabs.forEach((tab) => {
tab.addEventListener("click", () => {
const target = tab.dataset.techTab;
technicalTabs.forEach((item) => item.classList.toggle("is-active", item === tab));
document.querySelectorAll(".technical-panel").forEach((panel) => {
panel.hidden = panel.id !== `technicalPanel-${target}`;
});
});
});
document.querySelectorAll(".code-copy").forEach((button) => {
button.addEventListener("click", async () => {
const target = document.getElementById(button.dataset.copyTarget);
if (!target) {
return;
}
try {
await navigator.clipboard.writeText(target.textContent || "");
toastLikeAlert("Code copied to clipboard.");
} catch {
toastLikeAlert(target.textContent || "");
}
});
});
}
function navigate(path) {
window.history.pushState({}, "", path);
handleRoute();
}
function handleRoute() {
const route = parseRoute(window.location.pathname);
stopAnalysisAnimation();
Object.values(routeViews).forEach((view) => {
view.hidden = true;
});
if (route.name === "upload") {
routeViews.upload.hidden = false;
syncSelectedFiles();
renderRecentUploads();
} else if (route.name === "analysis") {
routeViews.analysis.hidden = false;
const scan = resolveScan(route.scanId);
if (!scan) {
navigate("/upload");
return;
}
state.currentScan = scan;
if (scan.result) {
navigate(`/results/${scan.id}`);
return;
}
} else if (route.name === "results") {
routeViews.results.hidden = false;
const scan = resolveScan(route.scanId);
if (!scan || !scan.result) {
navigate("/history");
return;
}
state.currentScan = scan;
renderResultsPage(scan);
} else if (route.name === "history") {
routeViews.history.hidden = false;
renderHistoryPage();
} else if (route.name === "technical") {
routeViews.technical.hidden = false;
} else {
routeViews.landing.hidden = false;
}
updateNavState();
window.scrollTo({ top: 0, behavior: "smooth" });
}
function parseRoute(pathname) {
if (pathname === "/upload") {
return { name: "upload" };
}
if (pathname === "/history") {
return { name: "history" };
}
if (pathname === "/technical") {
return { name: "technical" };
}
const analysisMatch = pathname.match(/^\/analysis\/([^/]+)$/);
if (analysisMatch) {
return { name: "analysis", scanId: analysisMatch[1] };
}
const resultsMatch = pathname.match(/^\/results\/([^/]+)$/);
if (resultsMatch) {
return { name: "results", scanId: resultsMatch[1] };
}
return { name: "landing" };
}
function updateNavState() {
const pathname = window.location.pathname;
document.querySelectorAll("[data-nav]").forEach((link) => {
const key = link.dataset.nav;
const isActive =
(key === "home" && pathname === "/") ||
(key === "upload" && pathname.startsWith("/upload")) ||
(key === "history" && pathname.startsWith("/history")) ||
(key === "technical" && pathname.startsWith("/technical"));
link.classList.toggle("is-active", isActive);
});
}
function syncSelectedFiles() {
if (!state.selectedFiles.length) {
selectedFilePanel.hidden = true;
return;
}
const summary = createFileSummary(state.selectedFiles);
selectedFilePanel.hidden = false;
selectedFileName.textContent = formatDisplayFileName(summary.name);
selectedFileName.title = summary.name;
selectedFileMeta.textContent = `${summary.size}${summary.format}`;
selectedFileMeta.title = `${summary.name}${summary.size}${summary.format}`;
fileFormatValue.textContent = summary.format;
fileCountValue.textContent = String(summary.fileCount);
fileSizeValue.textContent = summary.size;
}
function createFileSummary(files) {
const name = files.length === 1 ? files[0].name : `${files.length} files selected`;
const sizeBytes = files.reduce((sum, file) => sum + file.size, 0);
const extension = formatFileTypeLabel(files[0]?.name || "");
return {
name,
size: formatBytes(sizeBytes),
sizeBytes,
format: extension,
fileCount: files.length,
};
}
function formatFileTypeLabel(fileName) {
const lowerName = String(fileName || "").toLowerCase();
if (!lowerName) {
return "VOLUME";
}
if (lowerName.endsWith(".nii.gz")) {
return "NII.GZ";
}
const segments = lowerName.split(".").filter(Boolean);
if (segments.length <= 1) {
return "VOLUME";
}
return segments[segments.length - 1].toUpperCase();
}
function formatDisplayFileName(fileName, maxLength = 56) {
const value = String(fileName || "").trim();
if (!value) {
return "Unnamed file";
}
if (value.length <= maxLength) {
return value;
}
const head = Math.ceil((maxLength - 1) * 0.6);
const tail = Math.floor((maxLength - 1) * 0.4);
return `${value.slice(0, head)}${value.slice(-tail)}`;
}
function showUploadError(message) {
uploadErrorBanner.hidden = false;
uploadErrorBanner.textContent = message;
}
function hideUploadError() {
uploadErrorBanner.hidden = true;
uploadErrorBanner.textContent = "";
}
function startAnalysis(scan, onStart) {
let progress = 12;
let stepIndex = 0;
analysisDetail.textContent = analysisMessages[stepIndex];
analysisProgressBar.style.width = `${progress}%`;
analysisProgressLabel.textContent = `${progress}% complete`;
setActiveAnalysisStep(stepIndex);
state.analysisStepTimer = window.setInterval(() => {
stepIndex = (stepIndex + 1) % analysisMessages.length;
analysisDetail.textContent = analysisMessages[stepIndex];
setActiveAnalysisStep(stepIndex);
}, 800);
state.analysisTimer = window.setInterval(() => {
progress = Math.min(progress + 9, 92);
analysisProgressBar.style.width = `${progress}%`;
analysisProgressLabel.textContent = `${progress}% complete`;
}, 550);
onStart();
}
function stopAnalysisAnimation() {
if (state.analysisTimer) {
window.clearInterval(state.analysisTimer);
state.analysisTimer = null;
}
if (state.analysisStepTimer) {
window.clearInterval(state.analysisStepTimer);
state.analysisStepTimer = null;
}
}
function setActiveAnalysisStep(stepIndex) {
analysisSteps.forEach((step, index) => {
step.classList.toggle("is-active", index === stepIndex);
});
}
async function analyzeSelectedFiles() {
const formData = new FormData();
state.selectedFiles.forEach((file) => {
formData.append("ct_scan", file);
});
const response = await fetch("/api/analyze", {
method: "POST",
body: formData,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "Analysis failed.");
}
return payload;
}
function finalizeAnalysis(scan, payload) {
stopAnalysisAnimation();
analysisProgressBar.style.width = "100%";
analysisProgressLabel.textContent = "100% complete";
const enriched = {
...scan,
status: payload.status || "Analysis complete",
analyzedAt: new Date().toISOString(),
result: payload,
};
state.currentScan = enriched;
state.analysisResultById.set(scan.id, enriched);
upsertHistory(enriched);
renderRecentUploads();
renderHistoryPage();
window.setTimeout(() => {
navigate(`/results/${scan.id}`);
}, 300);
}
function renderResultsPage(scan) {
const payload = scan.result;
const analysis = payload.analysis || {};
const overallRisk = String(analysis.overall_risk || "LOW").toUpperCase();
const nodules = (analysis.nodules || []).filter((nodule) => {
const filter = noduleFilter.value;
return filter === "ALL" || String(nodule.risk_level || "LOW").toUpperCase() === filter;
});
riskBanner.classList.remove("risk-banner--low", "risk-banner--medium", "risk-banner--high");
riskBanner.classList.add(`risk-banner--${overallRisk.toLowerCase()}`);
riskIcon.textContent = overallRisk.charAt(0);
riskLabel.textContent = `Patient Risk: ${overallRisk}`;
riskSummary.textContent = `Maximum malignancy: ${Number(analysis.risk_score || 0).toFixed(1)}%`;
riskRecommendation.textContent = payload.next_steps || "Consult physician for evaluation.";
noduleCount.textContent = String(analysis.num_nodules_detected || 0);
analysisDate.textContent = formatDate(scan.analyzedAt);
analysisStatus.textContent = payload.status || "Analysis complete";
resultsMeta.textContent = `${analysis.num_nodules_detected || 0} nodules detected • Analyzed ${formatDate(scan.analyzedAt)}`;
resultsBreadcrumb.textContent = `Home > History > Scan ${scan.id}`;
recommendationText.textContent = payload.next_steps || "Consult physician for evaluation.";
scanFileName.textContent = formatDisplayFileName(scan.fileSummary?.name || "-");
scanFileName.title = scan.fileSummary?.name || "-";
scanFileSize.textContent = scan.fileSummary?.size || "-";
scanFileFormat.textContent = scan.fileSummary?.format || "-";
scanFileFormat.title = scan.fileSummary?.format || "-";
renderTiming(payload.timing || {});
renderVisualization(payload.visualization);
renderNodules(nodules);
updateResultNeighborButtons(scan.id);
}
function renderTiming(timing) {
timingGrid.innerHTML = "";
[
["Preprocess", timing.preprocess_sec],
["Detection", timing.detect_sec],
["Total", timing.total_sec],
].forEach(([label, value]) => {
if (value == null) {
return;
}
const item = document.createElement("div");
item.innerHTML = `<span>${label}</span><strong>${Number(value).toFixed(1)}s</strong>`;
timingGrid.appendChild(item);
});
}
function renderVisualization(visualization) {
if (visualization) {
scanImage.src = `data:image/png;base64,${visualization}`;
scanImage.hidden = false;
scanPlaceholder.hidden = true;
return;
}
scanImage.hidden = true;
scanImage.removeAttribute("src");
scanPlaceholder.hidden = false;
}
function renderNodules(nodules) {
nodulesList.innerHTML = "";
if (!nodules.length) {
emptyState.hidden = false;
return;
}
emptyState.hidden = true;
nodules.forEach((nodule) => {
const fragment = noduleCardTemplate.content.cloneNode(true);
const riskLevel = String(nodule.risk_level || "LOW").toUpperCase();
const detection = Number(nodule.detection_confidence || 0);
const malignancy = Number(nodule.malignancy_probability || 0);
fragment.querySelector("h3").textContent = `Nodule #${nodule.nodule_id}`;
fragment.querySelector(".nodule-location").textContent = `Location: ${nodule.location}`;
fragment.querySelector(".detection-value").textContent = `${detection.toFixed(1)}%`;
fragment.querySelector(".malignancy-value").textContent = `${malignancy.toFixed(1)}%`;
fragment.querySelector(".nodule-recommendation").textContent = nodule.recommendation || "Consult physician.";
const pill = fragment.querySelector(".risk-pill");
pill.textContent = riskLevel;
pill.classList.add(`risk-pill--${riskLevel.toLowerCase()}`);
const detectionBar = fragment.querySelector(".detection-bar");
const malignancyBar = fragment.querySelector(".malignancy-bar");
detectionBar.style.background = progressGradient(detection);
malignancyBar.style.background = progressGradient(malignancy);
nodulesList.appendChild(fragment);
window.requestAnimationFrame(() => {
detectionBar.style.width = `${detection}%`;
malignancyBar.style.width = `${malignancy}%`;
});
});
}
function renderHistoryPage() {
historyGroups.innerHTML = "";
const searchValue = historySearch.value.trim().toLowerCase();
const riskValue = historyFilter.value;
const sortValue = historySort.value;
const items = [...state.history]
.filter((item) => {
const matchesSearch =
!searchValue ||
item.fileSummary.name.toLowerCase().includes(searchValue) ||
item.id.toLowerCase().includes(searchValue);
const risk = String(item.result?.analysis?.overall_risk || "LOW").toUpperCase();
const matchesRisk = riskValue === "ALL" || risk === riskValue;
return matchesSearch && matchesRisk;
})
.sort((a, b) => {
const aDate = new Date(a.analyzedAt || a.createdAt).getTime();
const bDate = new Date(b.analyzedAt || b.createdAt).getTime();
return sortValue === "OLDEST" ? aDate - bDate : bDate - aDate;
});
historySubtitle.textContent = `${items.length} scans analyzed`;
if (!items.length) {
historyGroups.innerHTML = `
<article class="card">
<h3>No scans yet</h3>
<p class="summary-text">Analyze a CT scan to start building your history.</p>
</article>
`;
return;
}
const grouped = groupByMonth(items);
Object.entries(grouped).forEach(([month, scans]) => {
const group = document.createElement("section");
group.className = "history-group";
const title = document.createElement("h2");
title.textContent = month;
group.appendChild(title);
scans.forEach((scan) => {
const fragment = historyCardTemplate.content.cloneNode(true);
const risk = String(scan.result?.analysis?.overall_risk || "LOW").toUpperCase();
const score = Number(scan.result?.analysis?.risk_score || 0);
const bar = fragment.querySelector(".history-bar");
bar.style.background = progressGradient(score);
fragment.querySelector(".history-card__date").textContent = formatDate(scan.analyzedAt || scan.createdAt, true);
fragment.querySelector("h3").textContent = `${scan.result?.analysis?.num_nodules_detected || 0} nodules detected`;
fragment.querySelector(".history-card__score").textContent = `Maximum malignancy: ${score.toFixed(1)}%`;
fragment.querySelector(".history-card__file").textContent = `${scan.fileSummary.name}${scan.fileSummary.size}`;
bar.style.width = `${score}%`;
const pill = fragment.querySelector(".risk-pill");
pill.textContent = risk;
pill.classList.add(`risk-pill--${risk.toLowerCase()}`);
const viewLink = fragment.querySelector("[data-history-view]");
viewLink.setAttribute("href", `/results/${scan.id}`);
viewLink.addEventListener("click", (event) => {
event.preventDefault();
navigate(`/results/${scan.id}`);
});
fragment.querySelector("[data-history-export]").addEventListener("click", () => {
state.currentScan = scan;
openModal("export");
});
group.appendChild(fragment);
});
historyGroups.appendChild(group);
});
}
function renderRecentUploads() {
recentUploads.innerHTML = "";
const items = state.history.slice(0, 3);
if (!items.length) {
recentUploads.innerHTML = `<p class="summary-text">No recent scans yet.</p>`;
return;
}
items.forEach((item) => {
const card = document.createElement("article");
card.className = "recent-upload-item";
card.innerHTML = `
<strong>${item.fileSummary.name}</strong>
<p>Uploaded ${timeAgo(item.analyzedAt || item.createdAt)}${item.fileSummary.size}</p>
`;
card.addEventListener("click", () => navigate(`/results/${item.id}`));
recentUploads.appendChild(card);
});
}
function resolveScan(scanId) {
if (state.currentScan?.id === scanId) {
return state.currentScan;
}
return state.history.find((item) => item.id === scanId) || null;
}
function upsertHistory(scan) {
const existingIndex = state.history.findIndex((item) => item.id === scan.id);
if (existingIndex >= 0) {
state.history[existingIndex] = scan;
} else {
state.history.unshift(scan);
}
saveHistory(state.history);
}
function loadHistory() {
try {
return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
} catch {
return [];
}
}
function saveHistory(history) {
const compactHistory = history
.slice(0, 20)
.map((scan) => ({
...scan,
result: scan.result
? {
...scan.result,
visualization: null,
}
: null,
}));
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(compactHistory));
state.history = compactHistory;
}
function buildSampleScan(scanId) {
const now = new Date().toISOString();
return {
id: scanId,
createdAt: now,
status: "Analyzing",
fileSummary: {
name: "sample_patient_scan.nii.gz",
size: "128.4 MB",
format: "NII.GZ",
fileCount: 1,
},
result: {
status: "Sample analysis complete",
next_steps: "Review the highlighted nodule and consider follow-up imaging in 6 months.",
analysis: {
num_nodules_detected: 2,
overall_risk: "MEDIUM",
risk_score: 54.2,
nodules: [
{
nodule_id: 1,
location: "(87, 40, 257)",
detection_confidence: 60.2,
malignancy_probability: 36.4,
risk_level: "MEDIUM",
recommendation: "Follow-up scan in 6 months.",
},
{
nodule_id: 2,
location: "(112, 63, 244)",
detection_confidence: 82.7,
malignancy_probability: 54.2,
risk_level: "HIGH",
recommendation: "Expedited physician review is advised.",
},
],
},
visualization: null,
timing: {
preprocess_sec: 7.2,
detect_sec: 18.4,
total_sec: 28.6,
},
},
};
}
function exportCurrentImage() {
if (scanImage.src) {
const link = document.createElement("a");
link.href = scanImage.src;
link.download = "oncovision-ct-visualization.png";
link.click();
return;
}
toastLikeAlert("No CT visualization is available for export yet.");
}
function navigateHistoryNeighbor(direction) {
if (!state.currentScan) {
return;
}
const index = state.history.findIndex((item) => item.id === state.currentScan.id);
if (index < 0) {
return;
}
const next = state.history[index + direction];
if (!next) {
return;
}
navigate(`/results/${next.id}`);
}
function updateResultNeighborButtons(scanId) {
const index = state.history.findIndex((item) => item.id === scanId);
previousScanButton.disabled = index <= 0;
nextScanButton.disabled = index < 0 || index >= state.history.length - 1;
}
function deleteCurrentScan() {
if (!state.currentScan) {
return;
}
const confirmed = window.confirm(`Delete ${state.currentScan.fileSummary?.name || state.currentScan.id} from local history?`);
if (!confirmed) {
return;
}
state.history = state.history.filter((item) => item.id !== state.currentScan.id);
saveHistory(state.history);
renderHistoryPage();
renderRecentUploads();
state.currentScan = null;
navigate("/history");
}
function confirmShare() {
const email = shareEmailInput.value.trim();
if (!email) {
toastLikeAlert("Enter an email address to share results.");
return;
}
const shareUrl = `${window.location.origin}/results/${state.currentScan?.id || ""}`;
const message = shareMessageInput.value.trim() || "Please review this scan.";
openModal(null);
toastLikeAlert(`Prepared share for ${email}\n\nLink: ${shareUrl}\n\nMessage: ${message}`);
}
function generatePdfReport(previewOnly) {
if (!state.currentScan?.result) {
toastLikeAlert("No scan result is available to export.");
return;
}
const scan = state.currentScan;
const analysis = scan.result.analysis || {};
const reportData = {
patientName: patientNameInput.value.trim() || "Not provided",
mrn: patientMrnInput.value.trim() || "Not provided",
dob: patientDobInput.value.trim() || "Not provided",
physicianNotes: physicianNotesInput.value.trim() || "No additional physician notes.",
nodules: analysis.nodules || [],
overallRisk: String(analysis.overall_risk || "LOW").toUpperCase(),
riskScore: Number(analysis.risk_score || 0).toFixed(1),
totalTime: Number(scan.result.timing?.total_sec || 0).toFixed(1),
scanDate: formatDate(scan.analyzedAt || scan.createdAt, true),
generatedAt: formatDate(new Date().toISOString(), true),
fileName: scan.fileSummary?.name || "-",
fileSize: scan.fileSummary?.size || "-",
nextSteps: scan.result.next_steps || "Consult physician for evaluation.",
clinicalHighlights: buildClinicalHighlights(analysis.nodules || [], scan.result.next_steps || "Consult physician for evaluation."),
visualizationSrc: scanImage.src || "",
riskColor: riskBanner.classList.contains("risk-banner--high")
? "#fee2e2"
: riskBanner.classList.contains("risk-banner--medium")
? "#fef3c7"
: "#d1fae5",
};
const reportHtml = buildPdfPreviewHtml(scan, reportData);
if (previewOnly) {
const reportWindow = window.open("", "_blank", "noopener,noreferrer,width=1100,height=900");
if (!reportWindow) {
toastLikeAlert("Popup blocked. Allow popups to preview the report.");
return;
}
reportWindow.document.open();
reportWindow.document.write(reportHtml);
reportWindow.document.close();
openModal(null);
return;
}
const jsPdfCtor = window.jspdf?.jsPDF || window.jsPDF;
if (!jsPdfCtor) {
toastLikeAlert("PDF library failed to load. Falling back to preview window.");
generatePdfReport(true);
return;
}
const filename = `OVX_Report_${scan.id}_${formatReportDate(new Date().toISOString())}.pdf`;
openModal(null);
createPdfDocument(scan, reportData, jsPdfCtor, filename).catch((error) => {
console.error("PDF generation failed", error);
toastLikeAlert("PDF generation failed. Opening preview instead.");
generatePdfReport(true);
});
}
function buildPdfPreviewHtml(scan, reportData) {
const visualization = reportData.visualizationSrc
? `<img src="${reportData.visualizationSrc}" alt="CT visualization" style="width:100%;height:auto;border:1px solid #d6dee8;border-radius:18px;">`
: "<div style='padding:32px;border:1px solid #d6dee8;border-radius:18px;background:#f8fafc;'>Visualization not available for this scan.</div>";
const nodulesHtml = reportData.nodules.length
? reportData.nodules.map((nodule) => `<div class="box" style="margin-bottom:12px"><h3>Nodule #${nodule.nodule_id}</h3><p>Risk: ${escapeHtml(nodule.risk_level || "LOW")}</p><p>Detection Confidence: ${Number(nodule.detection_confidence || 0).toFixed(1)}%</p><p>Malignancy Probability: ${Number(nodule.malignancy_probability || 0).toFixed(1)}%</p><p>Location: ${escapeHtml(nodule.location || "-")}</p><p>Recommendation: ${escapeHtml(nodule.recommendation || "Consult physician.")}</p></div>`).join("")
: "<p>No suspicious nodules were detected.</p>";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OVX Report ${scan.id}</title>
<style>
body{font-family:Inter,Arial,sans-serif;margin:0;color:#1f2937;background:#eef2f7}
.page{box-sizing:border-box;padding:22mm 18mm;min-height:297mm;page-break-after:always;position:relative;background:#fff}
.page:last-child{page-break-after:auto}
h1,h2,h3{color:#0a1628;margin:0 0 12px}
h1{font-size:30px} h2{font-size:24px} p,li{font-size:13px;line-height:1.6}
.brand{width:220px;margin-bottom:28px}
.risk{padding:16px;border-radius:10px;background:${reportData.riskColor};margin:18px 0}
.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
.box{border:1px solid #e5e7eb;border-radius:10px;padding:14px;background:#fff}
.footer{position:absolute;left:20mm;right:20mm;bottom:12mm;padding-top:6mm;border-top:1px solid #e5e7eb;font-size:10px;color:#6b7280;display:flex;justify-content:space-between}
.watermark{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}
.watermark img{width:320px;opacity:.055;filter:grayscale(1)}
.visual-shell{margin-top:14px;padding:14px;border:1px solid #d6dee8;border-radius:20px;background:linear-gradient(180deg,#f8fafc 0%,#eef4fb 100%)}
.visual-caption{margin-top:12px;font-size:12px;color:#475569}
</style>
</head>
<body>
<section class="page">
<div class="watermark"><img src="${window.location.origin}/assets/favicon.png" alt=""></div>
<img class="brand" src="${window.location.origin}/assets/logo.png" alt="OncoVision-X">
<h1>Lung Cancer Screening Analysis Report</h1>
<p>Scan ID: ${scan.id}</p>
<p>Scan Date: ${reportData.scanDate}</p>
<p>Report Generated: ${reportData.generatedAt}</p>
<div class="risk"><strong>Overall Risk: ${reportData.overallRisk}</strong><br>Maximum malignancy probability: ${reportData.riskScore}%</div>
<p>${escapeHtml(reportData.nextSteps)}</p>
<div class="box"><h3>Clinical Action Summary</h3>${reportData.clinicalHighlights.map((item) => `<p style="margin:0 0 8px">• ${escapeHtml(item)}</p>`).join("")}</div>
<div class="grid">
<div class="box"><strong>Patient</strong><p>${escapeHtml(reportData.patientName)}</p></div>
<div class="box"><strong>MRN</strong><p>${escapeHtml(reportData.mrn)}</p></div>
<div class="box"><strong>DOB</strong><p>${escapeHtml(reportData.dob)}</p></div>
<div class="box"><strong>File</strong><p>${escapeHtml(reportData.fileName)}</p></div>
<div class="box"><strong>Size</strong><p>${escapeHtml(reportData.fileSize)}</p></div>
<div class="box"><strong>Total Time</strong><p>${reportData.totalTime}s</p></div>
</div>
<div class="footer"><span>OncoVision-X • Confidential Report</span><span>Page 1 of 3</span></div>
</section>
<section class="page">
<div class="watermark"><img src="${window.location.origin}/assets/favicon.png" alt=""></div>
<h2>Detailed Findings</h2>
${nodulesHtml}
<div class="box"><h3>Physician Notes</h3><p>${escapeHtml(reportData.physicianNotes)}</p></div>
<div class="footer"><span>OncoVision-X • Professional Report</span><span>Page 2 of 3</span></div>
</section>
<section class="page">
<div class="watermark"><img src="${window.location.origin}/assets/favicon.png" alt=""></div>
<h2>Three-View Scan Review</h2>
<div class="visual-shell">
${visualization}
</div>
<p class="visual-caption">The axial, coronal, and sagittal CT views are preserved together on a single report page for review and sharing.</p>
<p style="margin-top:18px">This report is intended to support clinical review and does not replace professional medical judgment. All findings should be reviewed by a qualified physician or radiologist.</p>
<div class="footer"><span>OncoVision-X • Professional Report</span><span>Page 3 of 3</span></div>
</section>
</body>
</html>`;
}
async function createPdfDocument(scan, reportData, JsPdfCtor, filename) {
const doc = new JsPdfCtor({
unit: "mm",
format: "a4",
orientation: "portrait",
});
const margin = 16;
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const contentWidth = pageWidth - margin * 2;
const [logoSrc, watermarkSrc, visualizationSrc, visualizationSize, logoSize] = await Promise.all([
imageToDataUrl("/assets/logo.png"),
imageToDataUrl("/assets/favicon.png"),
reportData.visualizationSrc ? imageToDataUrl(reportData.visualizationSrc) : Promise.resolve(""),
reportData.visualizationSrc ? getImageSize(reportData.visualizationSrc) : Promise.resolve(null),
getImageSize("/assets/logo.png"),
]);
const viewImages = visualizationSrc ? await splitThreeViewVisualization(visualizationSrc) : [];
const detailedFindingsPages = estimateDetailedFindingsPages(
doc,
contentWidth,
reportData.nodules,
reportData.physicianNotes,
pageHeight,
);
const totalPages = 1 + detailedFindingsPages + Math.max(viewImages.length, 1);
drawPdfPageHeader(doc, {
title: "Lung Screening Imaging Report",
subtitleLines: [
`Scan ID: ${scan.id}`,
`Scan Date: ${reportData.scanDate}`,
`Report Generated: ${reportData.generatedAt}`,
"Prepared for clinical review and print distribution",
],
logoSrc,
logoSize,
watermarkSrc,
});
drawRiskBanner(doc, margin, 72, contentWidth, reportData);
let y = drawWrappedTextBlock(doc, reportData.nextSteps, margin, 98, contentWidth, {
fontSize: 11,
textColor: "#334155",
lineHeight: 5.8,
}) + 8;
y = drawHighlightsBlock(doc, margin, y, contentWidth, reportData.clinicalHighlights) + 6;
drawInfoGrid(doc, margin, y, contentWidth, [
["Patient", reportData.patientName],
["MRN", reportData.mrn],
["DOB", reportData.dob],
["File", reportData.fileName],
["Size", reportData.fileSize],
["Total Time", `${reportData.totalTime}s`],
]);
drawPdfFooter(doc, 1, totalPages);
doc.addPage();
let currentPageNumber = 2;
drawPdfPageHeader(doc, {
title: "Detailed Findings",
subtitleLines: [],
logoSrc,
logoSize,
watermarkSrc,
});
y = 64;
if (reportData.nodules.length) {
reportData.nodules.forEach((nodule, index) => {
const blockHeight = getNoduleBlockHeight(doc, contentWidth, nodule);
if (y + blockHeight > pageHeight - 26) {
drawPdfFooter(doc, currentPageNumber, totalPages);
doc.addPage();
currentPageNumber += 1;
drawPdfPageHeader(doc, {
title: "Detailed Findings",
subtitleLines: ["Continued findings for clinical review"],
logoSrc,
logoSize,
watermarkSrc,
});
y = 64;
}
drawNoduleBlock(doc, margin, y, contentWidth, index + 1, nodule);
y += blockHeight + 4;
});
} else {
y = drawWrappedTextBlock(doc, "No suspicious nodules were detected.", margin, y, contentWidth, {
fontSize: 11,
textColor: "#475569",
lineHeight: 5.8,
}) + 8;
}
const notesHeight = getNotesBlockHeight(doc, contentWidth, reportData.physicianNotes);
if (y + notesHeight > pageHeight - 26) {
drawPdfFooter(doc, currentPageNumber, totalPages);
doc.addPage();
currentPageNumber += 1;
drawPdfPageHeader(doc, {
title: "Physician Notes",
subtitleLines: ["Continued findings and clinician comments"],
logoSrc,
logoSize,
watermarkSrc,
});
y = 64;
}
drawNotesBlock(doc, margin, y, contentWidth, reportData.physicianNotes);
drawPdfFooter(doc, currentPageNumber, totalPages);
const labeledViews = viewImages.length
? viewImages.map((view, index) => ({
...view,
label: ["Axial View", "Coronal View", "Sagittal View"][index] || `View ${index + 1}`,
}))
: [{ src: visualizationSrc, label: "Scan View", width: 0, height: 0 }];
labeledViews.forEach((viewImage, index) => {
doc.addPage();
drawPdfPageHeader(doc, {
title: viewImage.label,
subtitleLines: [
"High-resolution imaging panel for clinical review",
`Scan ID: ${scan.id}`,
],
logoSrc,
logoSize,
watermarkSrc,
});
drawSingleViewPage(doc, {
x: margin,
y: 58,
width: contentWidth,
height: 172,
viewImage,
});
drawWrappedTextBlock(
doc,
"For interpretation and print review by qualified clinical staff.",
margin,
240,
contentWidth,
{
fontSize: 9,
textColor: "#64748b",
lineHeight: 4.6,
},
);
drawPdfFooter(doc, currentPageNumber + 1 + index, totalPages);
});
doc.save(filename);
toastLikeAlert("Report downloaded successfully.");
}
function drawPdfPageHeader(doc, { title, subtitleLines, logoSrc, logoSize, watermarkSrc }) {
if (watermarkSrc) {
drawPdfWatermark(doc, watermarkSrc);
}
if (logoSrc) {
const fittedLogo = fitImageInBox(
logoSize?.width || 540,
logoSize?.height || 140,
68,
22,
);
doc.addImage(logoSrc, "PNG", 16, 12, fittedLogo.width, fittedLogo.height);
}
doc.setFont("helvetica", "bold");
doc.setFontSize(19);
doc.setTextColor("#0f172a");
doc.text(title, 16, 39);
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.setTextColor("#475569");
subtitleLines.forEach((line, index) => {
doc.text(line, 16, 47 + index * 4.8);
});
}
function drawPdfWatermark(doc, watermarkSrc) {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
if (typeof doc.GState === "function" && typeof doc.setGState === "function") {
doc.setGState(new doc.GState({ opacity: 0.06 }));
doc.addImage(watermarkSrc, "PNG", pageWidth / 2 - 52, pageHeight / 2 - 52, 104, 104);
doc.setGState(new doc.GState({ opacity: 1 }));
return;
}
doc.addImage(watermarkSrc, "PNG", pageWidth / 2 - 44, pageHeight / 2 - 44, 88, 88);
}
function drawRiskBanner(doc, x, y, width, reportData) {
const rgb = hexToRgb(reportData.riskColor);
doc.setFillColor(rgb.r, rgb.g, rgb.b);
doc.roundedRect(x, y, width, 18, 4, 4, "F");
doc.setFont("helvetica", "bold");
doc.setFontSize(12);
doc.setTextColor("#0f172a");
doc.text(`Overall Risk: ${reportData.overallRisk}`, x + 4, y + 7);
doc.setFont("helvetica", "normal");
doc.setFontSize(10.5);
doc.text(`Maximum malignancy probability: ${reportData.riskScore}%`, x + 4, y + 13);
}
function drawInfoGrid(doc, x, y, width, items) {
const columns = 2;
const gap = 4;
const boxWidth = (width - gap) / columns;
const boxHeight = 18;
items.forEach(([label, value], index) => {
const col = index % columns;
const row = Math.floor(index / columns);
const boxX = x + col * (boxWidth + gap);
const boxY = y + row * (boxHeight + gap);
doc.setDrawColor("#d7e0ea");
doc.setFillColor(255, 255, 255);
doc.roundedRect(boxX, boxY, boxWidth, boxHeight, 3, 3, "FD");
doc.setFont("helvetica", "bold");
doc.setFontSize(9.5);
doc.setTextColor("#0f172a");
doc.text(label, boxX + 3, boxY + 6);
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.setTextColor("#475569");
const lines = doc.splitTextToSize(String(value), boxWidth - 6);
doc.text(lines[0] || "-", boxX + 3, boxY + 12);
});
}
function drawHighlightsBlock(doc, x, y, width, items) {
const lineCounts = items.map((item) => doc.splitTextToSize(`• ${String(item)}`, width - 8).length);
const blockHeight = 11 + lineCounts.reduce((sum, count) => sum + count * 5, 0);
doc.setDrawColor("#d7e0ea");
doc.setFillColor(255, 255, 255);
doc.roundedRect(x, y, width, blockHeight, 3, 3, "FD");
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor("#0f172a");
doc.text("Clinical Action Summary", x + 4, y + 7);
doc.setFont("helvetica", "normal");
doc.setFontSize(9.5);
doc.setTextColor("#475569");
let cursorY = y + 13;
items.forEach((item, index) => {
const lines = doc.splitTextToSize(`• ${String(item)}`, width - 8);
doc.text(lines, x + 4, cursorY);
cursorY += lines.length * 5;
});
return y + blockHeight;
}
function getNoduleBlockHeight(doc, width, nodule) {
const location = doc.splitTextToSize(`Location: ${String(nodule.location || "-")}`, width - 114);
const recommendation = doc.splitTextToSize(`Recommendation: ${String(nodule.recommendation || "Consult physician.")}`, width - 114);
const rightColumnLines = location.length + recommendation.length;
return Math.max(54, 14 + rightColumnLines * 5.2);
}
function estimateDetailedFindingsPages(doc, width, nodules, notes, pageHeight) {
let pages = 1;
let y = 40;
if (nodules.length) {
nodules.forEach((nodule) => {
const blockHeight = getNoduleBlockHeight(doc, width, nodule);
if (y + blockHeight > pageHeight - 26) {
pages += 1;
y = 40;
}
y += blockHeight + 4;
});
} else {
y += 14;
}
const notesHeight = getNotesBlockHeight(doc, width, notes);
if (y + notesHeight > pageHeight - 26) {
pages += 1;
}
return pages;
}
function drawNoduleBlock(doc, x, y, width, ordinal, nodule) {
const blockHeight = getNoduleBlockHeight(doc, width, nodule);
doc.setDrawColor("#d7e0ea");
doc.setFillColor(255, 255, 255);
doc.roundedRect(x, y, width, blockHeight, 3, 3, "FD");
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor("#0f172a");
doc.text(`Nodule #${nodule.nodule_id || ordinal}`, x + 4, y + 7);
doc.setFont("helvetica", "normal");
doc.setFontSize(9.5);
doc.setTextColor("#334155");
doc.text(`Risk: ${String(nodule.risk_level || "LOW").toUpperCase()}`, x + 4, y + 14);
doc.text(`Detection Confidence: ${Number(nodule.detection_confidence || 0).toFixed(1)}%`, x + 4, y + 20);
doc.text(`Malignancy Probability: ${Number(nodule.malignancy_probability || 0).toFixed(1)}%`, x + 4, y + 26);
const location = doc.splitTextToSize(`Location: ${String(nodule.location || "-")}`, width - 114);
const recommendation = doc.splitTextToSize(`Recommendation: ${String(nodule.recommendation || "Consult physician.")}`, width - 114);
doc.text(location, x + 110, y + 14);
doc.text(recommendation, x + 110, y + 14 + location.length * 5.2 + 2);
}
function getNotesBlockHeight(doc, width, notes) {
const lines = doc.splitTextToSize(String(notes || ""), width - 8);
return Math.max(38, 16 + lines.length * 5);
}
function drawNotesBlock(doc, x, y, width, notes) {
const blockHeight = getNotesBlockHeight(doc, width, notes);
doc.setDrawColor("#d7e0ea");
doc.setFillColor(255, 255, 255);
doc.roundedRect(x, y, width, blockHeight, 3, 3, "FD");
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor("#0f172a");
doc.text("Physician Notes", x + 4, y + 7);
drawWrappedTextBlock(doc, notes, x + 4, y + 14, width - 8, {
fontSize: 9.5,
textColor: "#475569",
lineHeight: 5,
});
}
function drawSingleViewPage(doc, { x, y, width, height, viewImage }) {
doc.setDrawColor("#d7e0ea");
doc.setFillColor(248, 250, 252);
doc.roundedRect(x, y, width, height, 6, 6, "FD");
if (!viewImage?.src) {
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
doc.setTextColor("#64748b");
doc.text("Visualization not available for this scan.", x + width / 2, y + height / 2, { align: "center" });
return;
}
const fitted = fitImageInBox(
viewImage.width || 1,
viewImage.height || 1,
width - 12,
height - 12,
);
doc.addImage(
viewImage.src,
"PNG",
x + 6 + (width - 12 - fitted.width) / 2,
y + 6 + (height - 12 - fitted.height) / 2,
fitted.width,
fitted.height,
);
}
function drawPdfFooter(doc, pageNumber, totalPages) {
const pageHeight = doc.internal.pageSize.getHeight();
doc.setDrawColor("#d7e0ea");
doc.line(16, pageHeight - 14, 194, pageHeight - 14);
doc.setFont("helvetica", "normal");
doc.setFontSize(8.5);
doc.setTextColor("#64748b");
doc.text("OncoVision-X • Professional Report", 16, pageHeight - 8);
doc.text(pageNumberLabel(pageNumber, totalPages), 194, pageHeight - 8, { align: "right" });
}
function drawWrappedTextBlock(doc, text, x, y, width, options = {}) {
const {
fontSize = 10.5,
textColor = "#334155",
lineHeight = 5,
} = options;
doc.setFont("helvetica", "normal");
doc.setFontSize(fontSize);
doc.setTextColor(textColor);
const lines = doc.splitTextToSize(String(text || ""), width);
doc.text(lines, x, y);
return y + lines.length * lineHeight;
}
function pageNumberLabel(pageNumber, totalPages) {
return `Page ${pageNumber} of ${totalPages}`;
}
function buildClinicalHighlights(nodules, nextSteps) {
const items = [];
if (nextSteps) {
items.push(nextSteps);
}
if (!nodules.length) {
items.push("No suspicious nodules were identified on the current review.");
return items;
}
const highRisk = nodules.filter((nodule) => String(nodule.risk_level || "").toUpperCase() === "HIGH").length;
const largestProbability = Math.max(...nodules.map((nodule) => Number(nodule.malignancy_probability || 0)));
items.push(`${nodules.length} finding${nodules.length > 1 ? "s are" : " is"} listed in this report for focused review.`);
if (highRisk > 0) {
items.push(`${highRisk} finding${highRisk > 1 ? "s are" : " is"} categorized as high risk and should be prioritized during review.`);
} else if (largestProbability >= 45) {
items.push("Intermediate-risk imaging features are present, so interval comparison is recommended.");
} else {
items.push("The visible findings are lower risk and are best managed with surveillance and future comparison.");
}
items.push("Compare the listed lesion locations with prior chest imaging whenever earlier studies are available.");
return items.slice(0, 4);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function imageToDataUrl(src) {
if (!src) {
return Promise.resolve("");
}
if (src.startsWith("data:")) {
return Promise.resolve(src);
}
return fetch(src)
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to load image: ${src}`);
}
return response.blob();
})
.then((blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(String(reader.result || ""));
reader.onerror = reject;
reader.readAsDataURL(blob);
}));
}
function getImageSize(src) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = reject;
image.src = src;
});
}
function splitThreeViewVisualization(src) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
const segmentWidth = Math.floor(image.naturalWidth / 3);
const views = [];
for (let index = 0; index < 3; index += 1) {
const canvas = document.createElement("canvas");
const cropWidth = index === 2 ? image.naturalWidth - segmentWidth * 2 : segmentWidth;
canvas.width = cropWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d");
if (!context) {
reject(new Error("Canvas context unavailable for visualization split"));
return;
}
context.drawImage(
image,
index * segmentWidth,
0,
cropWidth,
image.naturalHeight,
0,
0,
cropWidth,
image.naturalHeight,
);
views.push({
src: canvas.toDataURL("image/png"),
width: cropWidth,
height: image.naturalHeight,
});
}
resolve(views);
};
image.onerror = reject;
image.src = src;
});
}
function fitImageInBox(sourceWidth, sourceHeight, maxWidth, maxHeight) {
if (!sourceWidth || !sourceHeight) {
return { width: maxWidth, height: maxHeight };
}
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
return {
width: sourceWidth * scale,
height: sourceHeight * scale,
};
}
function hexToRgb(hex) {
const value = hex.replace("#", "");
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function openModal(type) {
settingsModal.hidden = true;
helpModal.hidden = true;
exportModal.hidden = true;
shareModal.hidden = true;
modalBackdrop.hidden = type == null;
state.activeModal = type;
if (type === "settings") {
settingsModal.hidden = false;
} else if (type === "help") {
helpModal.hidden = false;
} else if (type === "export") {
exportModal.hidden = false;
} else if (type === "share") {
shareModal.hidden = false;
}
}
function groupByMonth(items) {
return items.reduce((groups, item) => {
const key = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
}).format(new Date(item.analyzedAt || item.createdAt));
groups[key] = groups[key] || [];
groups[key].push(item);
return groups;
}, {});
}
function progressGradient(value) {
if (value < 30) {
return "linear-gradient(90deg, #34d399 0%, #10b981 100%)";
}
if (value < 70) {
return "linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%)";
}
return "linear-gradient(90deg, #f87171 0%, #ef4444 100%)";
}
function createScanId() {
return `scan-${Date.now().toString(36)}`;
}
function formatBytes(bytes) {
if (!bytes) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** index;
return `${value.toFixed(value >= 100 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDate(value, withTime = false) {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
...(withTime ? { hour: "numeric", minute: "2-digit" } : {}),
}).format(new Date(value));
}
function formatReportDate(value) {
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
function timeAgo(value) {
const diff = Date.now() - new Date(value).getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days <= 0) {
return "today";
}
if (days === 1) {
return "1 day ago";
}
return `${days} days ago`;
}
function toastLikeAlert(message) {
window.alert(message);
}