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