Spaces:
Sleeping
Sleeping
| /** | |
| * 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 --- | |
| }); | |