/** * 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 = `${message}`; 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 --- });