/** * @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} */ 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} */ 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 = `
${sample.title}
${sample.modality}
`; 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} */ 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 = '

Note: Hover functionality is not available for this result.

'; 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 = [ '', ].join('\n'); outputTextContainer.innerHTML = friendlyMessage; } else if ( error.details.error === 'Input too long' && error.details.max_length ) { const friendlyMessage = [ '', ].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} 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} 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(); } });