Spaces:
Running
Running
| /** | |
| * @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 MAX_LABEL_LENGTH = 60; | |
| const UI_CONFIG = { | |
| SCROLL_SMOOTH_BEHAVIOR: 'smooth', | |
| SCROLL_OFFSET_BUFFER: 100, | |
| DEFAULT_SAMPLE_ID: 'chest_xray', | |
| }; | |
| // === 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'; | |
| }); | |
| document.querySelectorAll('.source-excerpt').forEach((excerpt) => { | |
| excerpt.hidden = true; | |
| }); | |
| clearInputHighlight(); | |
| } | |
| // Add global click handler to clear highlights when clicking outside on mobile | |
| document.addEventListener('click', function (e) { | |
| if (isTouchDevice() && !e.target.closest('.text-span')) { | |
| clearAllHighlights(); | |
| } | |
| }); | |
| const predictButton = document.getElementById('predict-button'); | |
| const inputText = document.getElementById('input-text'); | |
| const outputTextContainer = document.getElementById('output-text'); | |
| const findingsCards = document.getElementById('findings-cards'); | |
| const rawOutput = document.getElementById('raw-output'); | |
| const promptOutput = document.getElementById('prompt-output'); | |
| const studioState = document.getElementById('studio-state'); | |
| const outputViewTabs = Array.from( | |
| document.querySelectorAll('[data-output-view]'), | |
| ); | |
| let preferredStructuredView = 'report'; | |
| const instructionsEl = document.querySelector('.instructions'); | |
| const loadingOverlay = document.getElementById('loading-overlay'); | |
| let processingLoadingTimer = null; | |
| let originalInputText = ''; | |
| let sampleRunQueued = false; | |
| let sampleLoadTimer = null; | |
| // 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-flash-latest': { | |
| text: 'Gemini Flash (latest)', | |
| link: 'https://ai.google.dev/gemini-api/docs/models', | |
| }, | |
| }; | |
| /** | |
| * 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.setAttribute('aria-pressed', 'false'); | |
| 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'); | |
| btn.setAttribute('aria-pressed', 'false'); | |
| }); | |
| this.classList.add('active'); | |
| this.setAttribute('aria-pressed', 'true'); | |
| }); | |
| sampleButtonsContainer.appendChild(button); | |
| }); | |
| sampleButtonsContainer.addEventListener('keydown', handleSampleRailKeydown); | |
| const hasIncomingState = inputText.value.trim().length > 0; | |
| const defaultSample = sortedSamples.find( | |
| (sample) => sample.id === UI_CONFIG.DEFAULT_SAMPLE_ID, | |
| ); | |
| if (!hasIncomingState && defaultSample) { | |
| const defaultButton = sampleButtonsContainer.querySelector( | |
| `[data-sample-id="${UI_CONFIG.DEFAULT_SAMPLE_ID}"]`, | |
| ); | |
| if (defaultButton) { | |
| defaultButton.classList.add('active'); | |
| defaultButton.setAttribute('aria-pressed', 'true'); | |
| if (window.matchMedia('(max-width: 768px)').matches) { | |
| sampleButtonsContainer.scrollLeft = Math.max( | |
| 0, | |
| defaultButton.offsetLeft - sampleButtonsContainer.clientWidth / 2, | |
| ); | |
| } | |
| } | |
| loadSampleReport(defaultSample, { scroll: false }); | |
| } | |
| } | |
| /** | |
| * Supports arrow-key navigation across the horizontal sample rail. | |
| * @param {KeyboardEvent} event - Keyboard event from the rail | |
| */ | |
| function handleSampleRailKeydown(event) { | |
| if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; | |
| const buttons = Array.from( | |
| event.currentTarget.querySelectorAll('.sample-button'), | |
| ); | |
| const currentIndex = buttons.indexOf(document.activeElement); | |
| if (currentIndex < 0) return; | |
| event.preventDefault(); | |
| let nextIndex = currentIndex; | |
| if (event.key === 'ArrowLeft') nextIndex = Math.max(0, currentIndex - 1); | |
| if (event.key === 'ArrowRight') { | |
| nextIndex = Math.min(buttons.length - 1, currentIndex + 1); | |
| } | |
| if (event.key === 'Home') nextIndex = 0; | |
| if (event.key === 'End') nextIndex = buttons.length - 1; | |
| buttons[nextIndex].focus(); | |
| buttons[nextIndex].scrollIntoView({ block: 'nearest', inline: 'center' }); | |
| } | |
| /** | |
| * Loads a sample report into the input area and automatically processes it. | |
| * @param {Object} sample - The sample report data object | |
| */ | |
| function loadSampleReport(sample, options = {}) { | |
| if (options.scroll !== false) scrollToOutput(); | |
| if (sampleLoadTimer) { | |
| clearTimeout(sampleLoadTimer); | |
| sampleLoadTimer = null; | |
| } | |
| // 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 = 'none'; | |
| currentSampleId = sample.id; | |
| if (studioState) studioState.textContent = 'Loading cached example…'; | |
| // Automatically enable cache for sample reports | |
| if (cacheToggle) { | |
| cacheToggle.checked = true; | |
| // Trigger the change event to update model dropdown state | |
| updateModelDropdownState(); | |
| } | |
| if (predictButton.disabled) { | |
| sampleRunQueued = true; | |
| return; | |
| } | |
| sampleLoadTimer = setTimeout(() => { | |
| sampleLoadTimer = null; | |
| if (predictButton.disabled) { | |
| sampleRunQueued = true; | |
| return; | |
| } | |
| predictButton.click(); | |
| }, 100); | |
| } | |
| loadSampleReports(); | |
| /** | |
| * 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 (studioState) { | |
| studioState.textContent = | |
| cacheEnabled && currentSampleId | |
| ? 'Loading cached example…' | |
| : 'Running live extraction…'; | |
| } | |
| resetStudioMetrics(); | |
| 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 Flash (latest)'; | |
| 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 = ''; | |
| rawOutput?.replaceChildren(); | |
| promptOutput?.replaceChildren(); | |
| if (findingsCards) findingsCards.textContent = 'Structuring report…'; | |
| if (promptToggle) { | |
| promptToggle.checked = false; | |
| showPromptView(false); | |
| } | |
| const mobilePromptToggle = document.getElementById('prompt-toggle-mobile'); | |
| if (mobilePromptToggle) mobilePromptToggle.checked = false; | |
| selectOutputView('cards'); | |
| setOutputViewsAvailable(false); | |
| 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, | |
| }); | |
| // A newer sample selection is waiting; discard this response and run it next. | |
| if (sampleRunQueued) return; | |
| 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(); | |
| // A sample can be selected while response parsing is in progress. | |
| if (sampleRunQueued) return; | |
| // Cached samples should feel instant and never simulate provider latency. | |
| if (data.from_cache) { | |
| const loaderMessage = document.querySelector('.loader-message'); | |
| if (loaderMessage) { | |
| loaderMessage.textContent = | |
| 'Loading LangExtract Result from Cache...'; | |
| } | |
| } | |
| if (data.sanitized_input && data.sanitized_input !== originalInputText) { | |
| const inputText = document.getElementById('input-text'); | |
| if (inputText) { | |
| inputText.value = data.sanitized_input; | |
| originalInputText = data.sanitized_input; | |
| updateClearButtonState(); | |
| } | |
| } | |
| if (data.text) { | |
| if ( | |
| data.segments && | |
| Array.isArray(data.segments) && | |
| data.segments.length > 0 | |
| ) { | |
| renderSegments(data.segments); | |
| renderFindingCards(data.segments); | |
| updateStudioMetrics(data); | |
| setOutputViewsAvailable(true); | |
| 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'); | |
| const pre = document.createElement('pre'); | |
| pre.style.whiteSpace = 'pre-wrap'; | |
| pre.style.wordWrap = 'break-word'; | |
| pre.textContent = JSON.stringify(rawData, null, 2); | |
| rawOutput.replaceChildren(pre); | |
| rawOutput._jsonData = rawData; | |
| } else { | |
| 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'; | |
| } | |
| if (promptOutput) { | |
| const promptText = data.raw_prompt || 'Prompt data not available.'; | |
| if ( | |
| typeof marked !== 'undefined' && | |
| typeof DOMPurify !== 'undefined' && | |
| data.raw_prompt | |
| ) { | |
| // Render markdown with syntax highlighting support | |
| promptOutput.innerHTML = DOMPurify.sanitize( | |
| marked.parse(promptText), | |
| ); | |
| } else { | |
| // Fallback to plain text | |
| promptOutput.textContent = promptText; | |
| } | |
| promptToggle.checked = false; | |
| showPromptView(false); | |
| } | |
| selectOutputView(preferredStructuredView); | |
| const hasIntervals = data.segments.some( | |
| (segment) => segment.intervals && segment.intervals.length > 0, | |
| ); | |
| instructionsEl.replaceChildren(); | |
| if (!hasIntervals) { | |
| instructionsEl.innerHTML = | |
| '<p><strong>Note:</strong> Hover functionality is not available for this result.</p>'; | |
| instructionsEl.style.display = 'block'; | |
| } else { | |
| instructionsEl.style.display = 'none'; | |
| } | |
| if (studioState) { | |
| studioState.textContent = data.from_cache | |
| ? 'Cached sample · ready' | |
| : 'Live extraction · ready'; | |
| } | |
| } else { | |
| outputTextContainer.textContent = data.text; | |
| if (findingsCards) { | |
| findingsCards.textContent = data.text; | |
| } | |
| selectOutputView('cards'); | |
| resetStudioMetrics(); | |
| setOutputViewsAvailable(false); | |
| if (studioState) { | |
| studioState.textContent = 'Response returned · no structured items'; | |
| } | |
| instructionsEl.style.display = 'none'; | |
| } | |
| } else { | |
| outputTextContainer.textContent = 'No content returned from server.'; | |
| if (findingsCards) { | |
| findingsCards.textContent = 'No content returned from server.'; | |
| } | |
| selectOutputView('cards'); | |
| resetStudioMetrics(); | |
| setOutputViewsAvailable(false); | |
| if (studioState) | |
| studioState.textContent = 'No structured result returned'; | |
| instructionsEl.style.display = 'none'; | |
| } | |
| } catch (error) { | |
| if (sampleRunQueued) return; | |
| 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}`; | |
| } | |
| if (findingsCards) { | |
| findingsCards.textContent = outputTextContainer.textContent; | |
| } | |
| selectOutputView('cards'); | |
| resetStudioMetrics(); | |
| setOutputViewsAvailable(false); | |
| if (studioState) studioState.textContent = 'Unable to structure report'; | |
| 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 && typeof gsap !== 'undefined') { | |
| gsap.killTweensOf([message, spinner]); | |
| gsap.set([message, spinner], { clearProps: 'all' }); | |
| } | |
| predictButton.disabled = false; | |
| predictButton.textContent = 'Process'; | |
| updateCacheStatus(); | |
| if (sampleRunQueued) { | |
| sampleRunQueued = false; | |
| setTimeout(() => predictButton.click(), 0); | |
| } | |
| } | |
| }); | |
| /** | |
| * 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; | |
| } | |
| } | |
| /** | |
| * Renders every returned segment exactly once as a scan-friendly card. | |
| * @param {Array<Object>} segments - Ordered segments from the API response | |
| */ | |
| function renderFindingCards(segments) { | |
| if (!findingsCards) return; | |
| findingsCards.innerHTML = ''; | |
| const cardsFragment = document.createDocumentFragment(); | |
| segments.forEach((segment, index) => { | |
| const card = document.createElement('article'); | |
| card.className = 'finding-card'; | |
| card.dataset.segmentIndex = String(index); | |
| card.dataset.segmentType = segment.type || 'body'; | |
| const cardHeader = document.createElement('div'); | |
| cardHeader.className = 'finding-card-header'; | |
| const label = document.createElement('span'); | |
| label.className = 'finding-card-label'; | |
| label.textContent = segment.label || segment.type || 'Finding'; | |
| cardHeader.appendChild(label); | |
| const badges = document.createElement('span'); | |
| badges.className = 'finding-card-badges'; | |
| const significanceLevel = String( | |
| segment.significance || '', | |
| ).toLowerCase(); | |
| if ( | |
| significanceLevel === 'significant' || | |
| significanceLevel === 'minor' | |
| ) { | |
| const significance = document.createElement('span'); | |
| significance.className = `finding-badge significance-${significanceLevel}`; | |
| significance.textContent = significanceLevel; | |
| badges.appendChild(significance); | |
| } | |
| const grounded = Boolean(segment.intervals && segment.intervals.length); | |
| const grounding = document.createElement('span'); | |
| grounding.className = `finding-badge ${grounded ? 'is-grounded' : 'is-ungrounded'}`; | |
| grounding.textContent = grounded ? 'Source grounded' : 'No source span'; | |
| badges.appendChild(grounding); | |
| cardHeader.appendChild(badges); | |
| card.appendChild(cardHeader); | |
| const content = document.createElement('p'); | |
| content.className = 'finding-card-content'; | |
| content.appendChild(createContentWithIntervalSpans(segment)); | |
| card.appendChild(content); | |
| const sourceExcerpt = document.createElement('div'); | |
| sourceExcerpt.className = 'source-excerpt'; | |
| sourceExcerpt.hidden = true; | |
| sourceExcerpt.setAttribute('aria-live', 'polite'); | |
| card.appendChild(sourceExcerpt); | |
| cardsFragment.appendChild(card); | |
| }); | |
| findingsCards.appendChild(cardsFragment); | |
| } | |
| /** | |
| * Updates factual counters derived directly from the extraction response. | |
| * @param {Object} data - Successful API response | |
| */ | |
| function updateStudioMetrics(data) { | |
| const segments = Array.isArray(data.segments) ? data.segments : []; | |
| const counts = { | |
| items: segments.length, | |
| grounded: segments.filter((segment) => segment.intervals?.length).length, | |
| significant: segments.filter( | |
| (segment) => | |
| String(segment.significance || '').toLowerCase() === 'significant', | |
| ).length, | |
| minor: segments.filter( | |
| (segment) => | |
| String(segment.significance || '').toLowerCase() === 'minor', | |
| ).length, | |
| }; | |
| Object.entries(counts).forEach(([name, value]) => { | |
| const metric = document.getElementById(`metric-${name}`); | |
| if (metric) metric.textContent = String(value); | |
| }); | |
| } | |
| /** Clears counters while a request is running or has no structured result. */ | |
| function resetStudioMetrics() { | |
| ['items', 'grounded', 'significant', 'minor'].forEach((name) => { | |
| const metric = document.getElementById(`metric-${name}`); | |
| if (metric) metric.textContent = '—'; | |
| }); | |
| } | |
| /** | |
| * 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'); | |
| contentSpan.tabIndex = 0; | |
| contentSpan.setAttribute('role', 'button'); | |
| contentSpan.setAttribute( | |
| 'aria-label', | |
| `Show source for ${segment.label || segment.type || 'extracted item'}`, | |
| ); | |
| // 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 < 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(); | |
| const getSourceExcerpt = () => | |
| contentSpan.closest('.finding-card')?.querySelector('.source-excerpt'); | |
| const showGrounding = ({ focusInput = true } = {}) => { | |
| contentSpan.classList.add('highlight'); | |
| const startPos = parseInt(contentSpan.dataset.startPos); | |
| const endPos = parseInt(contentSpan.dataset.endPos); | |
| if (!isNaN(startPos) && !isNaN(endPos)) { | |
| highlightInputText(startPos, endPos, { focusInput }); | |
| const sourceExcerpt = getSourceExcerpt(); | |
| if (!focusInput && sourceExcerpt) { | |
| sourceExcerpt.textContent = originalInputText.slice(startPos, endPos); | |
| sourceExcerpt.hidden = false; | |
| } | |
| } | |
| }; | |
| const hideGrounding = () => { | |
| contentSpan.classList.remove('highlight'); | |
| const sourceExcerpt = getSourceExcerpt(); | |
| if (sourceExcerpt) sourceExcerpt.hidden = true; | |
| clearInputHighlight(); | |
| }; | |
| if (isDesktop) { | |
| // Desktop: Hover-based highlighting | |
| contentSpan.addEventListener('mouseenter', showGrounding); | |
| contentSpan.addEventListener('mouseleave', hideGrounding); | |
| contentSpan.addEventListener('focus', () => | |
| showGrounding({ focusInput: false }), | |
| ); | |
| contentSpan.addEventListener('blur', hideGrounding); | |
| } else { | |
| // Mobile: one click event avoids a touchstart/click double-toggle. | |
| contentSpan.addEventListener('click', function (e) { | |
| e.preventDefault(); | |
| handleMobileHighlight(contentSpan); | |
| }); | |
| } | |
| contentSpan.addEventListener('keydown', function (event) { | |
| if (event.key !== 'Enter' && event.key !== ' ') return; | |
| event.preventDefault(); | |
| if (isTouchDevice()) { | |
| handleMobileHighlight(contentSpan); | |
| } else { | |
| showGrounding({ focusInput: false }); | |
| } | |
| }); | |
| } | |
| /** | |
| * 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)) { | |
| const card = span.closest('.finding-card'); | |
| if (card && window.matchMedia('(max-width: 768px)').matches) { | |
| const excerpt = card.querySelector('.source-excerpt'); | |
| if (excerpt) { | |
| excerpt.textContent = originalInputText.slice(startPos, endPos); | |
| excerpt.hidden = false; | |
| } | |
| } else { | |
| 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 | |
| * @param {{focusInput?: boolean}} options - Whether to move focus to the source | |
| */ | |
| function highlightInputText(startPos, endPos, { focusInput = true } = {}) { | |
| // Enable focus for programmatic text selection | |
| if (focusInput && isTouchDevice()) { | |
| allowInputFocus = true; | |
| } | |
| if (focusInput) inputText.focus(); | |
| if (typeof inputText.setSelectionRange === 'function') { | |
| inputText.setSelectionRange(startPos, endPos); | |
| scrollInputToRange(startPos, endPos); // Centre the selection in viewport | |
| } | |
| // Restore focus prevention | |
| if (focusInput && 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: prefersReducedMotion() | |
| ? 'auto' | |
| : 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; | |
| if (prefersReducedMotion()) { | |
| gsap.killTweensOf([message, spinner]); | |
| gsap.set([message, spinner], { clearProps: 'all' }); | |
| 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(); | |
| } | |
| } | |
| /** | |
| * Selects one of the three output representations. | |
| * @param {'cards'|'report'|'raw'} view - View to make visible | |
| */ | |
| function selectOutputView(view, { remember = false } = {}) { | |
| const requestedTab = outputViewTabs.find( | |
| (tab) => tab.dataset.outputView === view, | |
| ); | |
| if (requestedTab?.disabled) view = 'cards'; | |
| if (findingsCards) { | |
| findingsCards.style.display = view === 'cards' ? 'grid' : 'none'; | |
| findingsCards.setAttribute('aria-hidden', String(view !== 'cards')); | |
| } | |
| if (outputTextContainer) { | |
| outputTextContainer.style.display = view === 'report' ? 'block' : 'none'; | |
| outputTextContainer.setAttribute( | |
| 'aria-hidden', | |
| String(view !== 'report'), | |
| ); | |
| } | |
| if (rawOutput) { | |
| rawOutput.style.display = view === 'raw' ? 'block' : 'none'; | |
| rawOutput.setAttribute('aria-hidden', String(view !== 'raw')); | |
| } | |
| if (rawToggle) rawToggle.checked = view === 'raw'; | |
| const mobileRawToggle = document.getElementById('raw-toggle-mobile'); | |
| if (mobileRawToggle) mobileRawToggle.checked = view === 'raw'; | |
| outputViewTabs.forEach((tab) => { | |
| const selected = tab.dataset.outputView === view; | |
| tab.classList.toggle('active', selected); | |
| tab.setAttribute('aria-selected', String(selected)); | |
| tab.tabIndex = selected ? 0 : -1; | |
| }); | |
| if (remember) preferredStructuredView = view; | |
| } | |
| /** Enables alternate representations only when structured output exists. */ | |
| function setOutputViewsAvailable(available) { | |
| outputViewTabs.forEach((tab) => { | |
| if (tab.dataset.outputView === 'cards') return; | |
| tab.disabled = !available; | |
| tab.setAttribute('aria-disabled', String(!available)); | |
| }); | |
| } | |
| outputViewTabs.forEach((tab) => { | |
| tab.tabIndex = tab.getAttribute('aria-selected') === 'true' ? 0 : -1; | |
| tab.addEventListener('click', () => | |
| selectOutputView(tab.dataset.outputView, { remember: true }), | |
| ); | |
| tab.addEventListener('keydown', (event) => { | |
| if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) | |
| return; | |
| event.preventDefault(); | |
| const enabledTabs = outputViewTabs.filter( | |
| (candidate) => !candidate.disabled, | |
| ); | |
| const enabledIndex = enabledTabs.indexOf(tab); | |
| let nextIndex = enabledIndex; | |
| if (event.key === 'ArrowLeft') { | |
| nextIndex = | |
| (enabledIndex - 1 + enabledTabs.length) % enabledTabs.length; | |
| } | |
| if (event.key === 'ArrowRight') { | |
| nextIndex = (enabledIndex + 1) % enabledTabs.length; | |
| } | |
| if (event.key === 'Home') nextIndex = 0; | |
| if (event.key === 'End') nextIndex = enabledTabs.length - 1; | |
| enabledTabs[nextIndex].focus(); | |
| selectOutputView(enabledTabs[nextIndex].dataset.outputView, { | |
| remember: true, | |
| }); | |
| }); | |
| }); | |
| setOutputViewsAvailable(false); | |
| /** | |
| * 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; | |
| selectOutputView(showRaw ? 'raw' : 'cards', { remember: true }); | |
| 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')); | |
| }); | |
| } | |
| }); | |
| /** Returns whether the user has asked interfaces to minimize motion. */ | |
| function prefersReducedMotion() { | |
| return window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| } | |
| /** | |
| * 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: prefersReducedMotion() ? 'auto' : '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(); | |
| } | |
| }); | |