Spaces:
Paused
Paused
| /** | |
| * @fileoverview Interactive radiology report structuring demo interface. | |
| * | |
| * This script provides the frontend functionality for the radiology report | |
| * structuring application, including sample report loading, API communication, | |
| * and interactive hover-to-highlight functionality between structured output | |
| * and original input text. | |
| */ | |
| // Import copy functionality | |
| import { initCopyButton, updateCopyButtonState } from './copy.js'; | |
| // Import clear functionality | |
| import { initClearButton, updateClearButtonState } from './reset.js'; | |
| document.addEventListener('DOMContentLoaded', function () { | |
| // === CONFIGURATION CONSTANTS === | |
| const GRID_CONFIG = { | |
| MOBILE_MIN_WIDTH: 120, | |
| DESKTOP_MIN_WIDTH: 160, | |
| MOBILE_BREAKPOINT: 768, | |
| NARROW_BREAKPOINT: 360, | |
| MAX_LABEL_LENGTH: 60, | |
| BALANCE_DELAY: 100, | |
| RESIZE_DEBOUNCE: 250, | |
| }; | |
| const UI_CONFIG = { | |
| SCROLL_SMOOTH_BEHAVIOR: 'smooth', | |
| SCROLL_OFFSET_BUFFER: 100, | |
| }; | |
| // === GLOBAL STATE === | |
| // Variables are declared where they're first used to avoid redeclaration errors | |
| // === UTILITY FUNCTIONS === | |
| /** | |
| * Checks if the device is a touch-only device (no hover capability). | |
| * Uses CSS media queries to accurately detect hover capability rather than just touch presence. | |
| * @returns {boolean} True if it's a touch-only device, false if it can hover | |
| */ | |
| const isTouchDevice = () => | |
| !window.matchMedia('(hover: hover) and (pointer: fine)').matches; | |
| /** | |
| * Clears all highlights from text spans. | |
| */ | |
| function clearAllHighlights() { | |
| const spans = document.querySelectorAll('.text-span.highlight'); | |
| spans.forEach((span) => { | |
| span.classList.remove('highlight'); | |
| span.dataset.highlighted = 'false'; | |
| }); | |
| clearInputHighlight(); | |
| } | |
| // Add global click handler to clear highlights when clicking outside on mobile | |
| document.addEventListener('click', function (e) { | |
| if (isTouchDevice() && !e.target.classList.contains('text-span')) { | |
| clearAllHighlights(); | |
| } | |
| }); | |
| const predictButton = document.getElementById('predict-button'); | |
| const inputText = document.getElementById('input-text'); | |
| const outputTextContainer = document.getElementById('output-text'); | |
| const instructionsEl = document.querySelector('.instructions'); | |
| const loadingOverlay = document.getElementById('loading-overlay'); | |
| let processingLoadingTimer = null; | |
| let originalInputText = ''; | |
| // Disable virtual keyboard on mobile devices | |
| let allowInputFocus = false; | |
| if (isTouchDevice()) { | |
| // Prevent focus to avoid virtual keyboard, except during programmatic highlighting | |
| inputText.addEventListener('focus', function (e) { | |
| if (!allowInputFocus) { | |
| e.target.blur(); | |
| } | |
| }); | |
| } | |
| let sampleReportsData = null; | |
| let currentSampleId = null; | |
| // Model dropdown elements | |
| const modelSelect = document.getElementById('model-select'); | |
| const modelLink = document.getElementById('model-link'); | |
| /** | |
| * Mapping of model IDs to their display information. | |
| * @const {Object<string, {text: string, link: string}>} | |
| */ | |
| const modelInfo = { | |
| 'gemini-2.5-flash': { | |
| text: 'Gemini 2.5 Flash', | |
| link: 'https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash', | |
| }, | |
| 'gemini-2.5-pro': { | |
| text: 'Gemini 2.5 Pro', | |
| link: 'https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-pro', | |
| }, | |
| }; | |
| /** | |
| * Updates the model information display based on the selected model. | |
| */ | |
| function updateModelInfo() { | |
| const selectedModel = modelSelect.value; | |
| if (modelLink) modelLink.href = modelInfo[selectedModel].link; | |
| } | |
| if (modelSelect) { | |
| modelSelect.addEventListener('change', updateModelInfo); | |
| } | |
| // Cache optimization elements | |
| const cacheToggle = document.getElementById('cache-toggle'); | |
| // LX Toggle elements | |
| const promptToggle = document.getElementById('prompt-toggle'); | |
| const rawToggle = document.getElementById('raw-toggle'); | |
| // Initialize copy functionality | |
| initCopyButton(); | |
| // Initialize clear functionality | |
| initClearButton(); | |
| /** | |
| * Detect mobile devices and update placeholder text | |
| * Mobile UX does not have text entry to avoid disrupting the user interaction | |
| * with extractions in the output - users can only select from samples | |
| */ | |
| function updatePlaceholderForMobile() { | |
| const isMobile = | |
| /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || | |
| (navigator.maxTouchPoints && navigator.maxTouchPoints > 0); | |
| if (isMobile) { | |
| inputText.placeholder = 'Please select a sample from above...'; | |
| } | |
| } | |
| updatePlaceholderForMobile(); | |
| /** | |
| * Updates model dropdown state based on cache toggle. | |
| * When cache is enabled, model dropdown is disabled since cache is model-specific. | |
| */ | |
| function updateModelDropdownState() { | |
| if (modelSelect && cacheToggle) { | |
| modelSelect.disabled = cacheToggle.checked; | |
| // Add visual indication | |
| if (cacheToggle.checked) { | |
| modelSelect.style.opacity = '0.6'; | |
| modelSelect.style.cursor = 'not-allowed'; | |
| } else { | |
| modelSelect.style.opacity = '1'; | |
| modelSelect.style.cursor = 'pointer'; | |
| } | |
| } | |
| } | |
| /** | |
| * Handles cache toggle changes. | |
| */ | |
| if (cacheToggle) { | |
| cacheToggle.addEventListener('change', updateModelDropdownState); | |
| updateModelDropdownState(); | |
| } | |
| /** | |
| * Updates LX toggles state based on content availability. | |
| * Disables toggles when input is empty or no output is generated. | |
| */ | |
| function updateLXToggleStates() { | |
| const hasInput = inputText && inputText.value.trim().length > 0; | |
| const hasOutput = | |
| outputTextContainer && outputTextContainer.textContent.trim().length > 0; | |
| if (promptToggle) { | |
| promptToggle.disabled = !hasInput; | |
| if (!hasInput) { | |
| promptToggle.checked = false; | |
| promptToggle.style.opacity = '0.5'; | |
| promptToggle.style.cursor = 'not-allowed'; | |
| } else { | |
| promptToggle.style.opacity = '1'; | |
| promptToggle.style.cursor = 'pointer'; | |
| } | |
| // Synchronize mobile toggle state | |
| const mobilePromptToggle = document.getElementById( | |
| 'prompt-toggle-mobile', | |
| ); | |
| if (mobilePromptToggle) { | |
| mobilePromptToggle.disabled = !hasInput; | |
| mobilePromptToggle.checked = promptToggle.checked; | |
| mobilePromptToggle.style.opacity = promptToggle.style.opacity; | |
| mobilePromptToggle.style.cursor = promptToggle.style.cursor; | |
| } | |
| } | |
| if (rawToggle) { | |
| rawToggle.disabled = !hasOutput; | |
| if (!hasOutput) { | |
| rawToggle.checked = false; | |
| rawToggle.style.opacity = '0.5'; | |
| rawToggle.style.cursor = 'not-allowed'; | |
| } else { | |
| rawToggle.style.opacity = '1'; | |
| rawToggle.style.cursor = 'pointer'; | |
| } | |
| // Synchronize mobile toggle state | |
| const mobileRawToggle = document.getElementById('raw-toggle-mobile'); | |
| if (mobileRawToggle) { | |
| mobileRawToggle.disabled = !hasOutput; | |
| mobileRawToggle.checked = rawToggle.checked; | |
| mobileRawToggle.style.opacity = rawToggle.style.opacity; | |
| mobileRawToggle.style.cursor = rawToggle.style.cursor; | |
| } | |
| } | |
| } | |
| updateLXToggleStates(); | |
| updateCopyButtonState(); | |
| /** | |
| * Loads sample reports from the static JSON file. | |
| * @returns {Promise<void>} | |
| */ | |
| async function loadSampleReports() { | |
| try { | |
| const response = await fetch('/static/sample_reports.json'); | |
| const data = await response.json(); | |
| sampleReportsData = data; | |
| initializeSampleButtons(); | |
| } catch (error) { | |
| console.error('Failed to load sample reports:', error); | |
| } | |
| } | |
| /** | |
| * Initializes the sample report buttons in the UI. | |
| */ | |
| function initializeSampleButtons() { | |
| if (!sampleReportsData || !sampleReportsData.samples) return; | |
| const sampleButtonsContainer = document.querySelector('.sample-buttons'); | |
| if (!sampleButtonsContainer) return; | |
| sampleButtonsContainer.innerHTML = ''; | |
| const sortedSamples = [...sampleReportsData.samples].sort((a, b) => | |
| a.title.localeCompare(b.title), | |
| ); | |
| sortedSamples.forEach((sample) => { | |
| const button = document.createElement('button'); | |
| button.className = 'sample-button'; | |
| button.setAttribute('data-sample-id', sample.id); | |
| button.innerHTML = ` | |
| <div class="sample-button-content"> | |
| <div class="sample-title">${sample.title}</div> | |
| <div class="sample-meta"> | |
| <span class="sample-modality">${sample.modality}</span> | |
| </div> | |
| </div> | |
| `; | |
| const modalitySpan = button.querySelector('.sample-modality'); | |
| if (modalitySpan) { | |
| modalitySpan.classList.add(`mod-${sample.modality.toLowerCase()}`); | |
| } | |
| button.addEventListener('click', function () { | |
| loadSampleReport(sample); | |
| document | |
| .querySelectorAll('.sample-button.active') | |
| .forEach((btn) => btn.classList.remove('active')); | |
| this.classList.add('active'); | |
| }); | |
| sampleButtonsContainer.appendChild(button); | |
| }); | |
| setTimeout(() => { | |
| balanceByColumnCount(); | |
| }, GRID_CONFIG.BALANCE_DELAY); | |
| } | |
| /** | |
| * Balances sample button rows by calculating optimal column count for even distribution. | |
| * Keeps row-wise reading order while achieving visual balance (e.g., 5+5 instead of 6+4). | |
| * Uses responsive sizing for better mobile experience. | |
| */ | |
| function balanceByColumnCount() { | |
| const container = document.querySelector('.sample-buttons'); | |
| if (!container) { | |
| console.warn('Sample buttons container not found'); | |
| return; | |
| } | |
| const cards = container.querySelectorAll('.sample-button').length; | |
| const styles = getComputedStyle(container); | |
| const gap = parseFloat(styles.columnGap) || 12; | |
| const viewport = window.innerWidth; | |
| const minWidth = | |
| viewport <= GRID_CONFIG.MOBILE_BREAKPOINT | |
| ? GRID_CONFIG.MOBILE_MIN_WIDTH | |
| : GRID_CONFIG.DESKTOP_MIN_WIDTH; | |
| const containerWidth = container.clientWidth; | |
| const columnsFit = Math.max( | |
| 1, | |
| Math.floor((containerWidth + gap) / (minWidth + gap)), | |
| ); | |
| if (viewport <= GRID_CONFIG.NARROW_BREAKPOINT) { | |
| return; | |
| } | |
| // Find the column count that provides the most even distribution | |
| let bestCols = columnsFit; | |
| let bestRem = cards % columnsFit; | |
| for (let cols = columnsFit - 1; cols >= 1; cols--) { | |
| const rem = cards % cols; | |
| if (rem === 0) { | |
| bestCols = cols; | |
| break; // Perfect distribution found | |
| } | |
| if (rem > bestRem) continue; // Worse distribution, skip | |
| bestCols = cols; | |
| bestRem = rem; | |
| } | |
| // Mobile-specific logic: prefer 2-3 columns for better touch targets | |
| if (viewport <= GRID_CONFIG.MOBILE_BREAKPOINT) { | |
| if (bestCols === 1 && columnsFit >= 2) { | |
| bestCols = 2; // Force at least 2 columns on mobile | |
| } else if (bestCols > 3 && cards >= 6) { | |
| // If we have many columns, prefer 2-3 for mobile UX | |
| const cols2Rem = cards % 2; | |
| const cols3Rem = cards % 3; | |
| if (cols2Rem <= cols3Rem) { | |
| bestCols = 2; | |
| } else { | |
| bestCols = 3; | |
| } | |
| } | |
| } | |
| // Always apply the balanced column count for optimal visual distribution | |
| container.style.gridTemplateColumns = `repeat(${bestCols}, minmax(${minWidth}px, 1fr))`; | |
| } | |
| /** | |
| * Loads a sample report into the input area and automatically processes it. | |
| * @param {Object} sample - The sample report data object | |
| */ | |
| function loadSampleReport(sample) { | |
| scrollToOutput(); | |
| // Normalize line endings for sample text | |
| inputText.value = sample.text.replace(/\r\n?/g, '\n'); | |
| // Update clear button state after loading sample | |
| updateClearButtonState(); | |
| outputTextContainer.innerHTML = ''; | |
| instructionsEl.style.display = 'block'; | |
| currentSampleId = sample.id; | |
| // Automatically enable cache for sample reports | |
| if (cacheToggle) { | |
| cacheToggle.checked = true; | |
| // Trigger the change event to update model dropdown state | |
| updateModelDropdownState(); | |
| } | |
| setTimeout(() => { | |
| predictButton.click(); | |
| }, 100); | |
| } | |
| loadSampleReports(); | |
| let resizeTimeout; | |
| window.addEventListener('resize', () => { | |
| clearTimeout(resizeTimeout); | |
| resizeTimeout = setTimeout(() => { | |
| balanceByColumnCount(); | |
| }, GRID_CONFIG.RESIZE_DEBOUNCE); | |
| }); | |
| /** | |
| * Updates the cache status display in the UI. | |
| * @returns {Promise<void>} | |
| */ | |
| async function updateCacheStatus() { | |
| try { | |
| const response = await fetch('/cache/stats'); | |
| const stats = await response.json(); | |
| const statusEl = document.getElementById('cache-status'); | |
| if (statusEl && stats.total_entries > 0) { | |
| statusEl.textContent = `(${stats.sample_entries} samples cached)`; | |
| } else if (statusEl) { | |
| statusEl.textContent = ''; | |
| } | |
| } catch (e) { | |
| console.log('Cache stats not available'); | |
| } | |
| } | |
| updateCacheStatus(); | |
| inputText.addEventListener('input', function () { | |
| if ( | |
| currentSampleId && | |
| inputText.value !== | |
| sampleReportsData?.samples?.find((s) => s.id === currentSampleId)?.text | |
| ) { | |
| currentSampleId = null; | |
| document | |
| .querySelectorAll('.sample-button.active') | |
| .forEach((btn) => btn.classList.remove('active')); | |
| } | |
| // Uncheck cache when input text is modified (cache no longer applies) | |
| if (cacheToggle && cacheToggle.checked) { | |
| cacheToggle.checked = false; | |
| updateModelDropdownState(); // Re-enable model dropdown | |
| updateCacheStatus(); // Update cache status display | |
| } | |
| // Update LX toggle states based on input content | |
| updateLXToggleStates(); | |
| updateCopyButtonState(); | |
| }); | |
| predictButton.addEventListener('click', async function () { | |
| predictButton.disabled = true; | |
| predictButton.textContent = 'Processing...'; | |
| const cacheEnabled = cacheToggle ? cacheToggle.checked : true; | |
| if (processingLoadingTimer) clearTimeout(processingLoadingTimer); | |
| // Show loading overlay after 200ms | |
| processingLoadingTimer = setTimeout(() => { | |
| if (loadingOverlay) { | |
| loadingOverlay.style.display = 'flex'; | |
| const loaderMessage = document.querySelector('.loader-message'); | |
| if (loaderMessage) { | |
| const modelText = | |
| (modelSelect && modelInfo[modelSelect.value]?.text) || | |
| 'Gemini 2.5 Flash'; | |
| loaderMessage.textContent = `Running LangExtract with ${modelText}...`; | |
| } | |
| if (typeof gsap !== 'undefined') { | |
| startLoaderAnimation(); | |
| } | |
| } | |
| }, 200); | |
| inputText.value = inputText.value.replace(/\r\n?/g, '\n'); | |
| originalInputText = inputText.value; | |
| outputTextContainer.innerHTML = ''; | |
| updateLXToggleStates(); // Disable toggles when output is cleared | |
| updateCopyButtonState(); | |
| try { | |
| const useCache = cacheEnabled; | |
| const headers = { 'Content-Type': 'text/plain' }; | |
| if (modelSelect) { | |
| headers['X-Model-ID'] = modelSelect.value; | |
| } | |
| if (useCache) { | |
| headers['X-Use-Cache'] = 'true'; | |
| if (currentSampleId) { | |
| headers['X-Sample-ID'] = currentSampleId; | |
| } | |
| } else { | |
| headers['X-Use-Cache'] = 'false'; | |
| } | |
| const response = await fetch('/predict', { | |
| method: 'POST', | |
| headers: headers, | |
| body: originalInputText, | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| let errorJson; | |
| try { | |
| errorJson = JSON.parse(errorText); | |
| } catch (parseError) { | |
| throw new Error(errorText || 'unknown error'); | |
| } | |
| const error = new Error(errorJson.error || 'unknown error'); | |
| error.details = errorJson; | |
| throw error; | |
| } | |
| // Stop the initial overlay timer so it doesn't overwrite cache message | |
| if (processingLoadingTimer) { | |
| clearTimeout(processingLoadingTimer); | |
| processingLoadingTimer = null; | |
| } | |
| const data = await response.json(); | |
| // Handle cached results with simulated loading | |
| if (data.from_cache) { | |
| // Ensure overlay is visible (may not be if response was quick) | |
| if (loadingOverlay && loadingOverlay.style.display === 'none') { | |
| loadingOverlay.style.display = 'flex'; | |
| if (typeof gsap !== 'undefined') { | |
| startLoaderAnimation(); | |
| } | |
| } | |
| // Update loading message for cached results | |
| const loaderMessage = document.querySelector('.loader-message'); | |
| if (loaderMessage) { | |
| loaderMessage.textContent = | |
| 'Loading LangExtract Result from Cache...'; | |
| } | |
| // Add 1-2 second delay for cached results to simulate loading | |
| const delay = Math.random() * 1000 + 2000; // 2-3 seconds | |
| await new Promise((resolve) => setTimeout(resolve, delay)); | |
| } | |
| if (data.sanitized_input && data.sanitized_input !== originalInputText) { | |
| const inputText = document.getElementById('input-text'); | |
| if (inputText) { | |
| inputText.value = data.sanitized_input; | |
| updateClearButtonState(); | |
| } | |
| } | |
| if (data.text) { | |
| if ( | |
| data.segments && | |
| Array.isArray(data.segments) && | |
| data.segments.length > 0 | |
| ) { | |
| renderSegments(data.segments); | |
| updateLXToggleStates(); // Enable/update toggles when output is generated | |
| updateCopyButtonState(); | |
| // Update raw / prompt panes | |
| const rawOutput = document.getElementById('raw-output'); | |
| const promptOutput = document.getElementById('prompt-output'); | |
| if (rawToggle && rawOutput) { | |
| const rawData = data.annotated_document_json || { | |
| error: 'No annotated document data available', | |
| available_data: data, | |
| }; | |
| rawOutput.innerHTML = ''; | |
| // Check if JSONFormatter is available | |
| if (typeof window.JSONFormatter === 'undefined') { | |
| console.error('JSONFormatter library not loaded, using fallback'); | |
| rawOutput.innerHTML = '<pre style="white-space: pre-wrap; word-wrap: break-word;">' + | |
| JSON.stringify(rawData, null, 2) + '</pre>'; | |
| rawOutput._jsonData = rawData; | |
| return; | |
| } | |
| const formatter = new window.JSONFormatter(rawData, { | |
| hoverPreviewEnabled: true, | |
| animateOpen: false, | |
| animateClose: false, | |
| theme: 'light', | |
| open: true, | |
| }); | |
| const renderedElement = formatter.render(); | |
| rawOutput.appendChild(renderedElement); | |
| rawOutput._jsonFormatter = formatter; | |
| rawOutput._jsonData = rawData; | |
| setTimeout(() => { | |
| try { | |
| if (formatter.openAtDepth) { | |
| formatter.openAtDepth(3); | |
| } | |
| } catch (e) { | |
| // Ignore errors if formatter doesn't support openAtDepth | |
| } | |
| const togglers = rawOutput.querySelectorAll( | |
| '.json-formatter-toggler', | |
| ); | |
| togglers.forEach((toggler) => { | |
| try { | |
| toggler.click(); | |
| } catch (e) { | |
| // Ignore click errors on JSON formatter togglers | |
| } | |
| }); | |
| }, 10); | |
| rawToggle.checked = false; | |
| rawOutput.style.display = 'none'; | |
| outputTextContainer.style.display = 'block'; | |
| } | |
| if (promptOutput) { | |
| const promptText = data.raw_prompt || 'Prompt data not available.'; | |
| if (typeof marked !== 'undefined' && data.raw_prompt) { | |
| // Render markdown with syntax highlighting support | |
| promptOutput.innerHTML = marked.parse(promptText); | |
| } else { | |
| // Fallback to plain text | |
| promptOutput.textContent = promptText; | |
| } | |
| promptToggle.checked = false; | |
| showPromptView(false); | |
| } | |
| const hasIntervals = data.segments.some( | |
| (segment) => segment.intervals && segment.intervals.length > 0, | |
| ); | |
| instructionsEl.style.display = 'block'; | |
| if (!hasIntervals) { | |
| instructionsEl.innerHTML = | |
| '<p><strong>Note:</strong> Hover functionality is not available for this result.</p>'; | |
| } | |
| } else { | |
| outputTextContainer.textContent = data.text; | |
| instructionsEl.style.display = 'none'; | |
| } | |
| } else { | |
| outputTextContainer.textContent = 'No content returned from server.'; | |
| instructionsEl.style.display = 'none'; | |
| } | |
| } catch (error) { | |
| if (error.details && typeof error.details === 'object') { | |
| if (error.details.error === 'Empty input') { | |
| const friendlyMessage = [ | |
| '<div class="error-message-simple" role="alert">', | |
| ' <h3>📝 Input Required</h3>', | |
| ' <p>Please paste or type a radiology report in the input area.</p>', | |
| ' <p class="suggestion">You can try one of the sample reports below to see how the structuring works.</p>', | |
| '</div>', | |
| ].join('\n'); | |
| outputTextContainer.innerHTML = friendlyMessage; | |
| } else if ( | |
| error.details.error === 'Input too long' && | |
| error.details.max_length | |
| ) { | |
| const friendlyMessage = [ | |
| '<div class="error-message-simple" role="alert">', | |
| ' <h3>⚠️ Input Too Long</h3>', | |
| ` <p>Your input contains <strong>${originalInputText.length.toLocaleString()}</strong> characters, `, | |
| ` but this demo is limited to <strong>${error.details.max_length.toLocaleString()}</strong> characters `, | |
| " to reduce the load on this demo's Gemini API key.</p>", | |
| ' <p class="suggestion">Try using a shorter excerpt from your report, or focus on the most relevant sections.</p>', | |
| ' <div class="deploy-note">', | |
| ' <strong>💡 Tip:</strong> If you deploy the source code with your own Gemini API key, you can modify this limit.', | |
| ' </div>', | |
| '</div>', | |
| ].join('\n'); | |
| outputTextContainer.innerHTML = friendlyMessage; | |
| } else { | |
| let errorMessage = `Error: ${error.details.error}\n\n`; | |
| errorMessage += `${error.details.message}`; | |
| if (error.details.max_length) { | |
| errorMessage += `\n\nMaximum allowed length: ${error.details.max_length} characters`; | |
| } | |
| outputTextContainer.textContent = errorMessage; | |
| } | |
| } else { | |
| outputTextContainer.textContent = `Error: ${error.message}`; | |
| } | |
| instructionsEl.style.display = 'none'; | |
| } finally { | |
| if (processingLoadingTimer) { | |
| clearTimeout(processingLoadingTimer); | |
| processingLoadingTimer = null; | |
| } | |
| if (loadingOverlay) loadingOverlay.style.display = 'none'; | |
| const message = document.querySelector('.loader-message'); | |
| const spinner = document.querySelector('.spinner'); | |
| if (message && spinner) { | |
| gsap.killTweensOf([message, spinner]); | |
| gsap.set([message, spinner], { clearProps: 'all' }); | |
| } | |
| predictButton.disabled = false; | |
| predictButton.textContent = 'Process'; | |
| updateCacheStatus(); | |
| } | |
| }); | |
| /** | |
| * Renders segments as interactive elements in the output container. | |
| * @param {Array<Object>} segments - Array of segment objects from the API response | |
| */ | |
| function renderSegments(segments) { | |
| outputTextContainer.innerHTML = ''; | |
| const plainTextParts = []; // Collect plain text for data-copy | |
| const segmentsByType = { | |
| prefix: segments.filter((seg) => seg.type === 'prefix'), | |
| body: segments.filter((seg) => seg.type === 'body'), | |
| suffix: segments.filter((seg) => seg.type === 'suffix'), | |
| }; | |
| if (segmentsByType.prefix.length > 0) { | |
| // Check if there's an Examination segment that should get a header | |
| const examinationSegments = segmentsByType.prefix.filter( | |
| (seg) => seg.label && seg.label.toLowerCase() === 'examination', | |
| ); | |
| const otherPrefixSegments = segmentsByType.prefix.filter( | |
| (seg) => !seg.label || seg.label.toLowerCase() !== 'examination', | |
| ); | |
| // Render Examination segments with content as header (no "EXAMINATION:" prefix) | |
| if (examinationSegments.length > 0) { | |
| examinationSegments.forEach((segment) => { | |
| let content = segment.content; | |
| // Remove various examination prefixes | |
| const examPrefixes = ['EXAMINATION:', 'EXAM:', 'STUDY:']; | |
| const upperContent = content.toUpperCase(); | |
| for (const prefix of examPrefixes) { | |
| if (upperContent.startsWith(prefix)) { | |
| content = content.substring(prefix.length).trim(); | |
| break; | |
| } | |
| } | |
| // Use the clean content as the header text (capitalized) | |
| if (content) { | |
| appendSectionHeader(content.toUpperCase()); | |
| plainTextParts.push(content.toUpperCase()); | |
| } | |
| }); | |
| outputTextContainer.appendChild(document.createElement('br')); | |
| } | |
| // Render other prefix segments normally | |
| if (otherPrefixSegments.length > 0) { | |
| otherPrefixSegments.forEach((segment) => { | |
| outputTextContainer.appendChild(createSegmentElement(segment)); | |
| plainTextParts.push(segment.content); | |
| }); | |
| outputTextContainer.appendChild(document.createElement('br')); | |
| } | |
| } | |
| if (segmentsByType.body.length > 0) { | |
| appendSectionHeader('FINDINGS:'); | |
| plainTextParts.push('\nFINDINGS:'); | |
| const groupMap = new Map(); | |
| segmentsByType.body.forEach((seg) => { | |
| const rawLabel = seg.label || 'Other'; | |
| const parts = rawLabel.split(':'); | |
| const primary = parts[0].trim(); | |
| const sub = parts.slice(1).join(':').trim(); | |
| if (!groupMap.has(primary)) groupMap.set(primary, []); | |
| groupMap.get(primary).push({ segment: seg, sublabel: sub }); | |
| }); | |
| groupMap.forEach((items, primary) => { | |
| const primaryHeader = document.createElement('div'); | |
| primaryHeader.className = 'primary-label'; | |
| primaryHeader.textContent = primary; | |
| outputTextContainer.appendChild(primaryHeader); | |
| plainTextParts.push('\n' + primary); | |
| if (items.length === 1) { | |
| const p = document.createElement('p'); | |
| p.className = 'single-finding'; | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.classList.add('segment-sublabel'); | |
| if (items[0].sublabel) { | |
| labelSpan.textContent = `${items[0].sublabel}: `; | |
| p.appendChild(labelSpan); | |
| } | |
| p.appendChild(createContentWithIntervalSpans(items[0].segment)); | |
| outputTextContainer.appendChild(p); | |
| plainTextParts.push('- ' + p.textContent.trim()); | |
| } else { | |
| const ul = document.createElement('ul'); | |
| ul.className = 'finding-list'; | |
| outputTextContainer.appendChild(ul); | |
| items.forEach((item) => { | |
| const li = document.createElement('li'); | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.classList.add('segment-sublabel'); | |
| if (item.sublabel) { | |
| labelSpan.textContent = `${item.sublabel}: `; | |
| li.appendChild(labelSpan); | |
| } | |
| li.appendChild(createContentWithIntervalSpans(item.segment)); | |
| ul.appendChild(li); | |
| plainTextParts.push('• ' + li.textContent.trim()); | |
| }); | |
| } | |
| }); | |
| } | |
| if (segmentsByType.suffix.length > 0) { | |
| appendSectionHeader('IMPRESSION:'); | |
| plainTextParts.push('\nIMPRESSION:'); | |
| segmentsByType.suffix.forEach((segment) => { | |
| outputTextContainer.appendChild(createSegmentElement(segment)); | |
| plainTextParts.push(segment.content); | |
| }); | |
| } | |
| // Store pre-computed plain text for efficient copying | |
| const plainText = plainTextParts | |
| .join('\n') | |
| .replace(/\n{3,}/g, '\n\n') | |
| .trim(); | |
| const outputEl = document.getElementById('output-text'); | |
| if (outputEl) { | |
| outputEl.dataset.copy = plainText; | |
| } | |
| } | |
| /** | |
| * Helper function to create section headers. | |
| * @param {string} text - The header text to display | |
| */ | |
| function appendSectionHeader(text) { | |
| const header = document.createElement('div'); | |
| header.className = 'section-header'; | |
| header.textContent = text; | |
| outputTextContainer.appendChild(header); | |
| } | |
| /** | |
| * Creates a DOM element for a segment. | |
| * @param {Object} segment - The segment data object | |
| * @returns {HTMLElement} The created segment element | |
| */ | |
| function createSegmentElement(segment) { | |
| const segmentDiv = document.createElement('div'); | |
| segmentDiv.classList.add('segment', `segment-${segment.type}`); | |
| if (segment.type === 'body' && segment.label) { | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.classList.add('segment-label'); | |
| labelSpan.textContent = `${segment.label}: `; | |
| segmentDiv.appendChild(labelSpan); | |
| } | |
| segmentDiv.appendChild(createContentWithIntervalSpans(segment)); | |
| return segmentDiv; | |
| } | |
| /** | |
| * Creates content with interval spans for highlighting functionality. | |
| * @param {Object} segment - The content segment with intervals and metadata | |
| * @returns {DocumentFragment} Fragment containing the processed content | |
| */ | |
| function createContentWithIntervalSpans(segment) { | |
| const fragment = document.createDocumentFragment(); | |
| if (segment.intervals && segment.intervals.length > 0) { | |
| const contentSpan = createIntervalSpan(segment); | |
| addIntervalEventListeners(contentSpan); | |
| fragment.appendChild(contentSpan); | |
| } else { | |
| fragment.appendChild(createRegularSpan(segment)); | |
| } | |
| return fragment; | |
| } | |
| /** | |
| * Creates a span element for content with intervals (highlighting capability). | |
| * @param {Object} segment - The content segment | |
| * @returns {HTMLSpanElement} The created span element | |
| */ | |
| function createIntervalSpan(segment) { | |
| const interval = segment.intervals[0]; | |
| const contentSpan = document.createElement('span'); | |
| contentSpan.classList.add('text-span'); | |
| // Set data attributes for position tracking | |
| contentSpan.dataset.startPos = interval.startPos; | |
| contentSpan.dataset.endPos = interval.endPos; | |
| contentSpan.dataset.type = segment.type; | |
| contentSpan.dataset.label = segment.label || ''; | |
| // Handle label styling if present | |
| const labelInfo = extractLabelInfo(segment.content); | |
| if (labelInfo.hasLabel) { | |
| setupLabelSpan(contentSpan, labelInfo); | |
| } else { | |
| contentSpan.textContent = segment.content; | |
| } | |
| // Apply significance-based styling | |
| applySignificanceStyles(contentSpan, segment.significance); | |
| return contentSpan; | |
| } | |
| /** | |
| * Extracts label information from content. | |
| * @param {string} content - The content to analyze | |
| * @returns {Object} Label information object | |
| */ | |
| function extractLabelInfo(content) { | |
| const colonIndex = content.indexOf(':'); | |
| const hasLabel = | |
| colonIndex > 0 && colonIndex < GRID_CONFIG.MAX_LABEL_LENGTH; | |
| return { | |
| hasLabel, | |
| labelText: hasLabel ? content.slice(0, colonIndex) : '', | |
| restText: hasLabel ? content.slice(colonIndex) : content, | |
| }; | |
| } | |
| /** | |
| * Sets up span with label and content parts for CSS styling. | |
| * @param {HTMLSpanElement} contentSpan - The span to configure | |
| * @param {Object} labelInfo - Label information object | |
| */ | |
| function setupLabelSpan(contentSpan, labelInfo) { | |
| contentSpan.classList.add('has-label'); | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.className = 'label-part'; | |
| labelSpan.textContent = labelInfo.labelText; | |
| const contentPartSpan = document.createElement('span'); | |
| contentPartSpan.className = 'content-part'; | |
| contentPartSpan.textContent = labelInfo.restText; | |
| contentSpan.appendChild(labelSpan); | |
| contentSpan.appendChild(contentPartSpan); | |
| } | |
| /** | |
| * Applies significance-based CSS classes to content spans. | |
| * @param {HTMLSpanElement} span - The span to style | |
| * @param {string} significance - The significance level | |
| */ | |
| function applySignificanceStyles(span, significance) { | |
| if (significance) { | |
| const significanceLevel = (significance || '').toLowerCase(); | |
| if ( | |
| significanceLevel === 'minor' || | |
| significanceLevel === 'significant' | |
| ) { | |
| span.classList.add(`significance-${significanceLevel}`); | |
| } | |
| } | |
| } | |
| /** | |
| * Creates a regular span for content without intervals. | |
| * @param {Object} segment - The content segment | |
| * @returns {HTMLSpanElement} The created span element | |
| */ | |
| function createRegularSpan(segment) { | |
| const regularSpan = document.createElement('span'); | |
| regularSpan.textContent = segment.content; | |
| // Apply significance styling even for non-interval content | |
| applySignificanceStyles(regularSpan, segment.significance); | |
| return regularSpan; | |
| } | |
| /** | |
| * Adds event listeners for interval spans with distinct desktop/mobile interaction patterns. | |
| * Desktop: Hover to highlight/unhighlight instantly | |
| * Mobile: Tap to toggle highlight on/off | |
| * @param {HTMLSpanElement} contentSpan - The span to add listeners to | |
| */ | |
| function addIntervalEventListeners(contentSpan) { | |
| const isDesktop = !isTouchDevice(); | |
| if (isDesktop) { | |
| // Desktop: Hover-based highlighting | |
| contentSpan.addEventListener('mouseenter', function () { | |
| contentSpan.classList.add('highlight'); | |
| const startPos = parseInt(contentSpan.dataset.startPos); | |
| const endPos = parseInt(contentSpan.dataset.endPos); | |
| if (!isNaN(startPos) && !isNaN(endPos)) { | |
| highlightInputText(startPos, endPos); | |
| } | |
| }); | |
| contentSpan.addEventListener('mouseleave', function () { | |
| contentSpan.classList.remove('highlight'); | |
| clearInputHighlight(); | |
| }); | |
| } else { | |
| // Mobile: Tap-based highlighting (toggle) | |
| contentSpan.addEventListener('touchstart', function (e) { | |
| e.preventDefault(); | |
| handleMobileHighlight(contentSpan); | |
| }); | |
| contentSpan.addEventListener('click', function (e) { | |
| e.preventDefault(); | |
| handleMobileHighlight(contentSpan); | |
| }); | |
| } | |
| } | |
| /** | |
| * Handles mobile highlighting toggle for touch devices. | |
| * Toggles highlight on/off when tapping the same span, or switches to new span. | |
| * @param {HTMLSpanElement} span - The span to highlight | |
| */ | |
| function handleMobileHighlight(span) { | |
| const isCurrentlyHighlighted = span.classList.contains('highlight'); | |
| // Clear all highlights first | |
| clearAllHighlights(); | |
| // If this span wasn't highlighted before, highlight it now | |
| if (!isCurrentlyHighlighted) { | |
| span.classList.add('highlight'); | |
| span.dataset.highlighted = 'true'; | |
| const startPos = parseInt(span.dataset.startPos); | |
| const endPos = parseInt(span.dataset.endPos); | |
| if (!isNaN(startPos) && !isNaN(endPos)) { | |
| highlightInputText(startPos, endPos); | |
| } | |
| } else { | |
| // If it was highlighted, just clear (already done above) | |
| clearInputHighlight(); | |
| } | |
| } | |
| /** | |
| * Highlights text in the input textarea based on character positions. | |
| * @param {number} startPos - Starting character position | |
| * @param {number} endPos - Ending character position | |
| */ | |
| function highlightInputText(startPos, endPos) { | |
| // Enable focus for programmatic text selection | |
| if (isTouchDevice()) { | |
| allowInputFocus = true; | |
| } | |
| inputText.focus(); | |
| if (typeof inputText.setSelectionRange === 'function') { | |
| inputText.setSelectionRange(startPos, endPos); | |
| scrollInputToRange(startPos, endPos); // Centre the selection in viewport | |
| } | |
| // Restore focus prevention | |
| if (isTouchDevice()) { | |
| allowInputFocus = false; | |
| } | |
| } | |
| /** | |
| * Scrolls the textarea so the selected range is vertically centered in the viewport. | |
| * Uses a temporary clone to calculate precise text measurements for accurate positioning. | |
| * @param {number} startPos - Start position of the selection | |
| * @param {number} endPos - End position of the selection | |
| */ | |
| function scrollInputToRange(startPos, endPos) { | |
| const style = window.getComputedStyle(inputText); | |
| const clone = document.createElement('textarea'); | |
| // Clone essential styles so scrollHeight matches the real textarea | |
| const ESSENTIAL_STYLES = [ | |
| 'width', | |
| 'fontFamily', | |
| 'fontSize', | |
| 'fontWeight', | |
| 'lineHeight', | |
| 'letterSpacing', | |
| 'padding', | |
| 'border', | |
| 'boxSizing', | |
| ]; | |
| ESSENTIAL_STYLES.forEach((prop) => (clone.style[prop] = style[prop])); | |
| // Position clone off-screen for measurement | |
| Object.assign(clone.style, { | |
| position: 'absolute', | |
| top: '-9999px', | |
| height: 'auto', | |
| }); | |
| document.body.appendChild(clone); | |
| try { | |
| // Calculate height before the selection | |
| clone.value = originalInputText.slice(0, startPos); | |
| const heightBefore = clone.scrollHeight; | |
| // Calculate height of the selection itself | |
| clone.value = originalInputText.slice(startPos, endPos); | |
| const heightSelection = clone.scrollHeight; | |
| // Calculate optimal scroll position to center the selection | |
| const viewportHeight = inputText.clientHeight; | |
| const targetScrollTop = Math.max( | |
| 0, | |
| heightBefore - viewportHeight / 2 + heightSelection / 2, | |
| ); | |
| inputText.scrollTo({ | |
| top: targetScrollTop, | |
| behavior: UI_CONFIG.SCROLL_SMOOTH_BEHAVIOR, | |
| }); | |
| } finally { | |
| // Always cleanup the clone element | |
| document.body.removeChild(clone); | |
| } | |
| } | |
| /** | |
| * Starts the GSAP loader pulse animation. | |
| */ | |
| function startLoaderAnimation() { | |
| const message = document.querySelector('.loader-message'); | |
| const spinner = document.querySelector('.spinner'); | |
| if (!message || !spinner) return; | |
| gsap.killTweensOf([message, spinner]); | |
| gsap.set([message, spinner], { clearProps: 'all' }); | |
| gsap.to(spinner, { | |
| rotation: 360, | |
| duration: 1.8, | |
| ease: 'none', | |
| repeat: -1, | |
| }); | |
| gsap.fromTo( | |
| message, | |
| { | |
| opacity: 0.4, | |
| scale: 0.98, | |
| }, | |
| { | |
| opacity: 1, | |
| scale: 1, | |
| duration: 1.2, | |
| ease: 'power2.inOut', | |
| yoyo: true, | |
| repeat: -1, | |
| }, | |
| ); | |
| gsap.to(message, { | |
| color: '#4285F4', | |
| duration: 2, | |
| ease: 'sine.inOut', | |
| yoyo: true, | |
| repeat: -1, | |
| }); | |
| } | |
| /** | |
| * Clears any highlighting in the input textarea. | |
| */ | |
| function clearInputHighlight() { | |
| if (document.activeElement === inputText) { | |
| inputText.blur(); | |
| } | |
| } | |
| const rawOutput = document.getElementById('raw-output'); | |
| const promptOutput = document.getElementById('prompt-output'); | |
| /** | |
| * Shows or hides the prompt view panel. | |
| * @param {boolean} show - Whether to show the prompt view | |
| */ | |
| function showPromptView(show) { | |
| if (!promptOutput) return; | |
| promptOutput.style.display = show ? 'block' : 'none'; | |
| inputText.style.display = show ? 'none' : 'block'; | |
| } | |
| if (rawToggle) { | |
| rawToggle.addEventListener('change', () => { | |
| const showRaw = rawToggle.checked; | |
| rawOutput.style.display = showRaw ? 'block' : 'none'; | |
| outputTextContainer.style.display = showRaw ? 'none' : 'block'; | |
| const mobileRawToggle = document.getElementById('raw-toggle-mobile'); | |
| if (mobileRawToggle) { | |
| mobileRawToggle.checked = showRaw; | |
| } | |
| if (showRaw) { | |
| setTimeout(() => { | |
| const formatter = rawOutput._jsonFormatter; | |
| if (formatter && formatter.openAtDepth) { | |
| try { | |
| formatter.openAtDepth(3); | |
| return; | |
| } catch (e) { | |
| // Fall back to manual clicking | |
| } | |
| } | |
| // Fallback: manually click the root toggler if it's collapsed | |
| const rootToggler = rawOutput.querySelector( | |
| '.json-formatter-toggler', | |
| ); | |
| if (rootToggler) { | |
| const arrow = | |
| rootToggler.querySelector('.json-formatter-toggler-link') || | |
| rootToggler; | |
| const arrowText = arrow.textContent || arrow.innerText || ''; | |
| if (arrowText.includes('►') || !arrowText.includes('▼')) { | |
| try { | |
| rootToggler.click(); | |
| } catch (e) { | |
| console.error('Failed to expand JSON:', e); | |
| } | |
| } | |
| } | |
| }, 100); | |
| } | |
| }); | |
| } | |
| if (promptToggle) { | |
| promptToggle.addEventListener('change', () => { | |
| const showPrompt = promptToggle.checked; | |
| showPromptView(showPrompt); | |
| // Synchronize with mobile toggle | |
| const mobilePromptToggle = document.getElementById( | |
| 'prompt-toggle-mobile', | |
| ); | |
| if (mobilePromptToggle) { | |
| mobilePromptToggle.checked = showPrompt; | |
| } | |
| }); | |
| } | |
| // Mobile prompt toggle event handling | |
| const mobilePromptToggle = document.getElementById('prompt-toggle-mobile'); | |
| if (mobilePromptToggle && promptToggle) { | |
| mobilePromptToggle.addEventListener('change', () => { | |
| const showPrompt = mobilePromptToggle.checked; | |
| promptToggle.checked = showPrompt; | |
| showPromptView(showPrompt); | |
| }); | |
| } | |
| // Mobile raw toggle event handling | |
| const mobileRawToggle = document.getElementById('raw-toggle-mobile'); | |
| if (mobileRawToggle && rawToggle) { | |
| mobileRawToggle.addEventListener('change', () => { | |
| const showRaw = mobileRawToggle.checked; | |
| rawToggle.checked = showRaw; | |
| rawToggle.dispatchEvent(new Event('change')); | |
| }); | |
| } | |
| }); | |
| /** | |
| * Scrolls to the output panel to direct user focus to the results area. | |
| * Provides improved navigation experience for sample report selection workflow. | |
| */ | |
| function scrollToOutput() { | |
| const outputContainer = document.getElementById('output-container'); | |
| if (outputContainer) { | |
| // Smooth scroll to the output area | |
| outputContainer.scrollIntoView({ | |
| behavior: 'smooth', | |
| block: 'center', | |
| }); | |
| } | |
| } | |
| /** | |
| * Toggles the interface options panel between expanded and collapsed states. | |
| */ | |
| function toggleInterfaceOptions() { | |
| const content = document.getElementById('interface-options-content'); | |
| const icon = document.getElementById('interface-expand-icon'); | |
| if (content.style.display === 'none' || content.style.display === '') { | |
| content.style.display = 'block'; | |
| icon.classList.add('expanded'); | |
| } else { | |
| content.style.display = 'none'; | |
| icon.classList.remove('expanded'); | |
| } | |
| } | |
| // Set up event delegation for interface toggle | |
| document.addEventListener('click', (e) => { | |
| if (e.target.closest('[data-action="toggle-interface"]')) { | |
| toggleInterfaceOptions(); | |
| } | |
| }); | |