Spaces:
Running
Running
/** | |
* Copyright 2025 Google LLC | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
document.addEventListener('DOMContentLoaded', () => { | |
const infoDiv = document.querySelector('.info'); | |
const mainDiv = document.querySelector('.main'); | |
const reportSectionDiv = document.querySelector('.report-section'); | |
const viewDemoButton = document.getElementById('view-demo-button'); | |
const backToInfoButton = document.getElementById('back-to-info-button'); | |
const caseSelectorTabsContainer = document.getElementById('case-selector-tabs-container'); | |
const reportTextDisplay = document.getElementById('report-text-display'); | |
const explanationOutput = document.getElementById('explanation-output'); | |
const explanationContent = document.getElementById('explanation-content'); | |
const explanationError = document.getElementById('explanation-error'); | |
const imageContainer = document.getElementById('image-container'); | |
const reportImage = document.getElementById('report-image'); | |
const imageLoading = document.getElementById('image-loading'); | |
const imageError = document.getElementById('image-error'); | |
const imageModalityHeader = document.getElementById('image-modality-header'); // Get reference to the header | |
const ctImageNote = document.getElementById('ct-image-note'); | |
const appLoading = document.getElementById('app-loading'); | |
const appError = document.getElementById('app-error'); | |
let availableReports = []; | |
let currentReportName = null; | |
let currentReportDetails = null; | |
let explainAbortController = null; | |
let reportLoadAbortController = null; | |
let appLoadingTimeout = null; | |
let explanationLoadingTimer = null; | |
function initialize() { | |
try { | |
const reportsDataElement = document.getElementById('reports-data'); | |
if (reportsDataElement) { | |
availableReports = JSON.parse(reportsDataElement.textContent); | |
} else { | |
displayAppError("Failed to load report list."); | |
return; | |
} | |
} catch (e) { | |
displayAppError("Failed to parse report list."); | |
return; | |
} | |
if (availableReports.length === 0) { | |
displayAppError("No reports available."); | |
return; | |
} | |
if (viewDemoButton && infoDiv && mainDiv) { | |
viewDemoButton.addEventListener('click', () => { | |
infoDiv.style.display = 'none'; | |
mainDiv.style.display = 'grid'; | |
if (currentReportName) { | |
loadReportDetails(currentReportName); | |
} | |
}); | |
} | |
if (backToInfoButton && infoDiv && mainDiv) { | |
backToInfoButton.addEventListener('click', () => { | |
abortOngoingRequests(); | |
mainDiv.style.display = 'none'; | |
infoDiv.style.display = 'flex'; | |
clearAllOutputs(); | |
currentReportDetails = null; | |
reportImage.src = ''; | |
document.title = "Radiology Report Explainer"; | |
}); | |
} | |
if (caseSelectorTabsContainer) { | |
caseSelectorTabsContainer.addEventListener('click', handleCaseSelectionClick); | |
} | |
reportTextDisplay.addEventListener('click', handleSentenceClick); | |
const firstCaseButton = caseSelectorTabsContainer?.querySelector('.nav-button-case'); | |
if (firstCaseButton) { | |
currentReportName = firstCaseButton.dataset.reportName; | |
setActiveCaseButton(firstCaseButton); | |
loadReportDetails(currentReportName); | |
} else { | |
displayAppError("No cases found to load initially."); | |
} | |
} | |
function handleCaseSelectionClick(event) { | |
const clickedButton = event.target.closest('.nav-button-case'); | |
if (!clickedButton) return; | |
const selectedName = clickedButton.dataset.reportName; | |
if (selectedName && selectedName !== currentReportName) { | |
abortOngoingRequests(); | |
currentReportName = selectedName; | |
setActiveCaseButton(clickedButton); | |
loadReportDetails(currentReportName); | |
} | |
} | |
async function handleSentenceClick(event) { | |
const clickedElement = event.target; | |
if (!clickedElement.classList.contains('report-sentence') || clickedElement.tagName !== 'SPAN') return; | |
const sentenceText = clickedElement.dataset.sentence; | |
if (!sentenceText || !currentReportName) return; | |
abortOngoingRequests(['report']); | |
explainAbortController = new AbortController(); | |
document.querySelectorAll('#report-text-display .selected-sentence').forEach(el => el.classList.remove('selected-sentence')); | |
clickedElement.classList.add('selected-sentence'); | |
adjustExplanationPosition(clickedElement); | |
try { | |
await Promise.all([ | |
fetchExplanation(sentenceText, explainAbortController.signal), | |
]); | |
} catch (error) { | |
if (error.name !== 'AbortError') { | |
console.error("Error during sentence processing:", error); | |
} | |
} | |
} | |
async function loadReportDetails(reportName) { | |
abortOngoingRequests(); | |
reportLoadAbortController = new AbortController(); | |
const signal = reportLoadAbortController.signal; | |
setLoadingState(true, 'report'); | |
clearAllOutputs(true); | |
try { | |
const response = await fetch(`/get_report_details/${encodeURIComponent(reportName)}`, { signal }); | |
if (signal.aborted) return; | |
if (!response.ok) { | |
const errorData = await response.json().catch(() => ({ error: `HTTP error ${response.status}` })); | |
throw new Error(errorData.error || `HTTP error ${response.status}`); | |
} | |
currentReportDetails = await response.json(); | |
if (signal.aborted) return; | |
document.title = `${reportName} - Radiology Explainer`; | |
// Update the image modality header | |
if (imageModalityHeader && currentReportDetails.image_type) { | |
imageModalityHeader.textContent = currentReportDetails.image_type; | |
} | |
// Show/hide CT note based on image_type and image_file presence | |
if (ctImageNote) { | |
if (currentReportDetails.image_type === 'CT' && currentReportDetails.image_file) { | |
ctImageNote.style.display = 'block'; | |
} else { | |
ctImageNote.style.display = 'none'; | |
} | |
} | |
if (currentReportDetails.image_file) { | |
const imageUrl = `${currentReportDetails.image_file}`; | |
reportImage.onload = null; | |
reportImage.onerror = null; | |
reportImage.onload = () => { | |
imageLoading.style.display = 'none'; | |
reportImage.style.display = 'block'; | |
imageError.style.display = 'none'; | |
}; | |
reportImage.onerror = () => { | |
imageLoading.style.display = 'none'; | |
reportImage.style.display = 'none'; | |
displayImageError("Failed to load image file."); | |
}; | |
reportImage.src = imageUrl; | |
reportImage.alt = `Radiology Image for ${reportName}`; | |
} else { | |
displayImageError("Image path not configured for this report."); | |
if (ctImageNote) ctImageNote.style.display = 'none'; // Ensure note is hidden if no image path | |
} | |
renderReportTextWithLineBreaks(currentReportDetails.text || ''); | |
} catch (error) { | |
if (error.name !== 'AbortError') { | |
displayReportTextError(`Failed to load report: ${error.message}`); | |
// Clean up UI elements on report load error. | |
if (reportImage) { | |
reportImage.style.display = 'none'; | |
reportImage.src = ''; | |
reportImage.onload = null; | |
reportImage.onerror = null; | |
} | |
if (imageLoading) imageLoading.style.display = 'none'; | |
if (imageError) imageError.style.display = 'none'; | |
if (ctImageNote) ctImageNote.style.display = 'none'; | |
clearExplanationAndLocationUI(); | |
} | |
} finally { | |
if (reportLoadAbortController?.signal === signal) { | |
reportLoadAbortController = null; | |
} | |
if (!signal.aborted) { | |
setLoadingState(false, 'report'); | |
} | |
} | |
} | |
function renderReportTextWithLineBreaks(text) { | |
reportTextDisplay.innerHTML = ''; | |
reportTextDisplay.classList.remove('loading', 'error'); | |
const lines = text.split('\n'); | |
if (lines.length === 0 || (lines.length === 1 && !lines[0].trim())) { | |
reportTextDisplay.textContent = 'Report text is empty or could not be processed.'; | |
return; | |
} | |
lines.forEach((line, index) => { | |
const trimmedLine = line.trim(); | |
if (trimmedLine !== '') { | |
const sentences = splitSentences(trimmedLine); | |
sentences.forEach(sentence => { | |
if (sentence) { | |
const span = document.createElement('span'); | |
span.textContent = sentence + ' '; | |
if (!sentence.includes('Image source: ') && sentence.includes(' ') || !sentence.includes(':')) { | |
span.classList.add('report-sentence'); | |
} | |
span.dataset.sentence = sentence; | |
reportTextDisplay.appendChild(span); | |
} | |
}); | |
} | |
if (index < lines.length - 1) { | |
reportTextDisplay.appendChild(document.createElement('br')); | |
} | |
}); | |
} | |
async function fetchExplanation(sentence, signal) { | |
explanationError.style.display = 'none'; | |
if (!currentReportName) { | |
displayExplanationError("No report selected."); | |
return; | |
} | |
if (explanationLoadingTimer) { | |
clearTimeout(explanationLoadingTimer); | |
} | |
explanationLoadingTimer = setTimeout(() => { | |
if (!signal.aborted) { // Only add if not already aborted | |
explanationOutput.classList.add('loading'); | |
explanationContent.textContent = ''; | |
} | |
explanationLoadingTimer = null; // Timer has done its job or been cleared | |
}, 150); // 150ms delay, adjust as needed | |
try { | |
const response = await fetch('/explain', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, // prettier-ignore | |
body: JSON.stringify({ sentence, report_name: currentReportName }), | |
signal | |
}); | |
if (signal.aborted) return; | |
if (!response.ok) { | |
if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer); | |
explanationLoadingTimer = null; | |
explanationOutput.classList.remove('loading'); | |
const errorData = await response.json().catch(() => ({ error: `HTTP error ${response.status}` })); | |
throw new Error(errorData.error || `HTTP error ${response.status}`); | |
} | |
const data = await response.json(); | |
if (signal.aborted) return; | |
if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer); | |
explanationLoadingTimer = null; | |
explanationOutput.classList.remove('loading'); | |
requestAnimationFrame(() => { | |
explanationContent.textContent = data.explanation || "No explanation content received."; | |
adjustExplanationPosition(); | |
}); | |
} catch (error) { | |
if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer); | |
explanationLoadingTimer = null; | |
explanationOutput.classList.remove('loading'); | |
if (error.name !== 'AbortError') { | |
displayExplanationError(`Explanation Error: ${error.message}`); | |
} | |
} | |
} | |
function setLoadingState(isLoading, type = 'all') { | |
if (type === 'all' || type === 'report') { | |
if (isLoading) { | |
if (appLoadingTimeout) { | |
clearTimeout(appLoadingTimeout); | |
appLoadingTimeout = null; | |
} | |
// Cleanup immediate error/content states | |
if (appError) appError.style.display = 'none'; | |
if (reportImage) reportImage.style.display = 'none'; | |
if (imageError) imageError.style.display = 'none'; | |
if (ctImageNote) ctImageNote.style.display = 'none'; | |
clearExplanationAndLocationUI(); | |
appLoadingTimeout = setTimeout(() => { | |
if (appLoading) appLoading.style.display = 'block'; | |
// Show content-specific loaders only if timeout fires | |
if (reportTextDisplay) { | |
reportTextDisplay.innerHTML = 'Loading...'; | |
reportTextDisplay.classList.add('loading'); | |
reportTextDisplay.classList.remove('error'); | |
} | |
if (imageLoading) imageLoading.style.display = 'block'; | |
}, 200); // Delay for app and content loading indicators (e.g., 200ms) | |
} else { | |
if (appLoadingTimeout) { | |
clearTimeout(appLoadingTimeout); | |
appLoadingTimeout = null; | |
} | |
if (appLoading) appLoading.style.display = 'none'; | |
} | |
} | |
} | |
function clearAllOutputs(keepReportTextLoading = false) { | |
if (!keepReportTextLoading && reportTextDisplay) { | |
reportTextDisplay.innerHTML = 'Select a report to view its text.'; | |
reportTextDisplay.classList.remove('loading', 'error'); | |
} | |
if (reportImage) { | |
reportImage.style.display = 'none'; | |
reportImage.onload = null; | |
reportImage.onerror = null; | |
reportImage.src = ''; | |
} | |
if (imageError) imageError.style.display = 'none'; | |
if (ctImageNote) ctImageNote.style.display = 'none'; | |
clearExplanationAndLocationUI(); | |
if (appError) appError.style.display = 'none'; | |
// Reset image modality header on clear | |
if (imageModalityHeader) { | |
imageModalityHeader.textContent = 'Medical Image'; // Reset to default | |
} | |
} | |
function clearExplanationAndLocationUI() { | |
if (explanationContent) { | |
explanationContent.textContent = 'Click a sentence to see the explanation here.'; | |
} | |
if (explanationLoadingTimer) { // Clear any pending explanation loading timer | |
clearTimeout(explanationLoadingTimer); | |
explanationLoadingTimer = null; | |
} | |
explanationOutput.classList.remove('loading'); | |
explanationError.style.display = 'none'; | |
document.querySelectorAll('#report-text-display .selected-sentence').forEach(el => el.classList.remove('selected-sentence')); | |
} | |
function displayReportTextError(message) { | |
reportTextDisplay.innerHTML = `<span class="error-message">${message}</span>`; | |
reportTextDisplay.classList.add('error'); | |
reportTextDisplay.classList.remove('loading'); | |
} | |
function displayAppError(message) { | |
appError.textContent = `Error: ${message}`; | |
appError.style.display = 'block'; | |
appLoading.style.display = 'none'; | |
} | |
function displayImageError(message) { | |
imageError.textContent = message; | |
imageError.style.display = 'block'; | |
imageLoading.style.display = 'none'; | |
reportImage.style.display = 'none'; | |
if (ctImageNote) ctImageNote.style.display = 'none'; | |
} | |
function displayExplanationError(message) { | |
explanationError.textContent = message; | |
explanationError.style.display = 'block'; | |
explanationOutput.classList.remove('loading'); | |
if (explanationContent) explanationContent.textContent = ''; | |
} | |
function setActiveCaseButton(activeButton) { | |
if (!caseSelectorTabsContainer) return; | |
caseSelectorTabsContainer.querySelectorAll('.nav-button-case').forEach(btn => btn.classList.remove('active')); | |
if (activeButton) activeButton.classList.add('active'); | |
} | |
function splitSentences(text) { | |
if (!text) return []; | |
try { | |
if (typeof nlp !== 'function') { | |
const basicSentences = text.match(/[^.?!]+[.?!]['"]?(\s+|$)/g); | |
return basicSentences ? basicSentences.map(s => s.trim()).filter(s => s.length > 0) : []; | |
} | |
const doc = nlp(text); | |
return doc.sentences().out('array').map(s => s.trim()).filter(s => s.length > 0); | |
} catch (e) { | |
const basicSentences = text.match(/[^.?!]+[.?!]['"]?(\s+|$)/g); | |
return basicSentences ? basicSentences.map(s => s.trim()).filter(s => s.length > 0) : []; | |
} | |
} | |
function adjustExplanationPosition(clickedSentenceElement) { | |
const targetSentenceElement = clickedSentenceElement || document.querySelector('#report-text-display .selected-sentence'); | |
if (!targetSentenceElement) return; | |
const explanationSection = explanationOutput.closest('.explanation-section'); | |
if (explanationOutput && explanationSection && reportSectionDiv) { | |
const sentenceRect = targetSentenceElement.getBoundingClientRect(); | |
const explanationSectionRect = explanationSection.getBoundingClientRect(); | |
// Use actual offsetHeight, fallback if not rendered. Accurate after rAF. | |
const explanationHeight = explanationOutput.offsetHeight || 200; | |
// Initial top: align with sentence, relative to explanationSection. | |
let newTop = sentenceRect.top - explanationSectionRect.top; | |
// Absolute bottom of explanation box if placed at newTop. | |
const explanationBoxAbsoluteBottom = explanationSectionRect.top + newTop + explanationHeight + 15; // 15px margin | |
const viewportHeight = window.innerHeight; | |
const pageBottomOverflow = explanationBoxAbsoluteBottom - viewportHeight; | |
if (pageBottomOverflow > 0) { | |
// Adjust newTop upwards if overflowing viewport bottom. | |
newTop -= pageBottomOverflow; | |
} | |
// Prevent top from being negative (relative to its container). | |
newTop = Math.max(0, newTop); | |
explanationOutput.style.top = `${newTop}px`; | |
} | |
} | |
function abortOngoingRequests(excludeTypes = []) { | |
if (!excludeTypes.includes('report') && reportLoadAbortController) { | |
reportLoadAbortController.abort(); | |
reportLoadAbortController = null; | |
} | |
if (!excludeTypes.includes('explain') && explainAbortController) { | |
explainAbortController.abort(); | |
explainAbortController = null; | |
} | |
} | |
initialize(); | |
}); | |
// Make sure this is within your existing DOMContentLoaded listener, | |
// or wrap it in one if demo.js doesn't have a global one. | |
document.addEventListener('DOMContentLoaded', () => { | |
// ... (any existing JavaScript code in demo.js) | |
// --- BEGIN: Immersive Info Dialog Logic --- | |
const infoButton = document.getElementById('info-button'); | |
const immersiveDialogOverlay = document.getElementById('immersive-info-dialog'); | |
const dialogCloseButton = document.getElementById('dialog-close-button'); | |
if (infoButton && immersiveDialogOverlay && dialogCloseButton) { | |
const openDialog = () => { | |
immersiveDialogOverlay.style.display = 'flex'; // Use flex as per CSS | |
// Timeout to allow display:flex to apply before triggering transition | |
setTimeout(() => { | |
immersiveDialogOverlay.classList.add('active'); | |
}, 10); // Small delay | |
document.body.style.overflow = 'hidden'; // Prevent background scroll | |
}; | |
const closeDialog = () => { | |
immersiveDialogOverlay.classList.remove('active'); | |
// Wait for opacity transition to finish before setting display to none | |
setTimeout(() => { | |
immersiveDialogOverlay.style.display = 'none'; | |
}, 300); // Must match CSS transition duration | |
document.body.style.overflow = ''; // Restore background scroll | |
}; | |
infoButton.addEventListener('click', openDialog); | |
dialogCloseButton.addEventListener('click', closeDialog); | |
// Dismissible: Close when clicking on the overlay (backdrop) | |
immersiveDialogOverlay.addEventListener('click', (event) => { | |
if (event.target === immersiveDialogOverlay) { | |
closeDialog(); | |
} | |
}); | |
// Dismissible: Close with Escape key | |
document.addEventListener('keydown', (event) => { | |
if (event.key === 'Escape' && immersiveDialogOverlay.classList.contains('active')) { | |
closeDialog(); | |
} | |
}); | |
} else { | |
// Log errors if elements are not found, helps in debugging | |
if (!infoButton) console.error('Dialog trigger button (#info-button) not found.'); | |
if (!immersiveDialogOverlay) console.error('Immersive dialog (#immersive-info-dialog) not found.'); | |
if (!dialogCloseButton) console.error('Dialog close button (#dialog-close-button) not found.'); | |
} | |
// --- END: Immersive Info Dialog Logic --- | |
}); | |