radextract / static /script.js
goelak's picture
Refine demo navigation
04e16a6
Raw
History Blame Contribute Delete
54.1 kB
/**
* @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();
}
});