codebook / potato /static /span-link-manager.js
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
52.9 kB
/**
* Span Link Manager
*
* Handles the creation, management, and visualization of links/relationships
* between spans in the Potato annotation platform.
*/
class SpanLinkManager {
constructor(schemaName, spanSchemaName) {
this.schemaName = schemaName;
this.spanSchemaName = spanSchemaName;
this.selectedSpans = [];
this.currentLinkType = null;
this.links = [];
this.isLinkMode = false;
this.linkTypeConfig = {};
// DOM element references
this.container = document.getElementById(schemaName);
this.selectedSpansDisplay = document.getElementById(`${schemaName}_selected_spans`);
this.linkList = document.getElementById(`${schemaName}_link_list`);
this.createButton = document.getElementById(`${schemaName}_create_link`);
this.clearButton = document.getElementById(`${schemaName}_clear_selection`);
this.showArcsCheckbox = document.getElementById(`${schemaName}_show_arcs`);
this.linkDataInput = document.getElementById(`${schemaName}_link_data`);
// Arc rendering
this.arcsContainer = null;
this.init();
}
init() {
console.log('[SpanLinkManager] init() called for schema:', this.schemaName);
if (!this.container) {
console.warn(`SpanLinkManager: Container not found for schema ${this.schemaName}`);
return;
}
// Parse link type configurations
this.parseLinkTypeConfigs();
console.log('[SpanLinkManager] Link type configs:', this.linkTypeConfig);
// Set up event listeners
this.setupEventListeners();
// Create arc rendering container
this.createArcsContainer();
// Set up observer to re-render arcs when spans are added/removed
this.setupSpanObserver();
// Load existing links if any
this.loadExistingLinks();
console.log(`[SpanLinkManager] Initialization complete for schema: ${this.schemaName}`);
console.log('[SpanLinkManager] Post-init state:', {
hasArcsContainer: !!this.arcsContainer,
hasArcSpacer: !!this.arcSpacer,
hasTextWrapper: !!this.textWrapper
});
}
/**
* Set up MutationObserver to watch for span overlay changes.
* Re-renders arcs when spans are added, removed, or modified.
*/
setupSpanObserver() {
const targetNode = this.textWrapper || document.getElementById('instance-text');
if (!targetNode) return;
// Debounce re-renders to avoid excessive updates
let renderTimeout = null;
const debouncedRender = () => {
if (renderTimeout) clearTimeout(renderTimeout);
renderTimeout = setTimeout(() => {
if (this.links.length > 0) {
console.log('[SpanLinkManager] Span change detected, re-rendering arcs');
this.renderArcs();
}
}, 100);
};
this.spanObserver = new MutationObserver((mutations) => {
// Check if any mutation involves span overlays
const hasSpanChanges = mutations.some(mutation => {
// Check added nodes
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.classList?.contains('span-overlay-pure') ||
node.classList?.contains('span-overlay') ||
node.querySelector?.('.span-overlay-pure, .span-overlay')) {
return true;
}
}
}
// Check removed nodes
for (const node of mutation.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.classList?.contains('span-overlay-pure') ||
node.classList?.contains('span-overlay')) {
return true;
}
}
}
return false;
});
if (hasSpanChanges) {
debouncedRender();
}
});
this.spanObserver.observe(targetNode, {
childList: true,
subtree: true
});
}
parseLinkTypeConfigs() {
const linkTypes = this.container.querySelectorAll('.span-link-type');
linkTypes.forEach(lt => {
const name = lt.dataset.linkType;
this.linkTypeConfig[name] = {
directed: lt.dataset.directed === 'true',
maxSpans: parseInt(lt.dataset.maxSpans) || 2,
color: lt.dataset.color || '#dc2626',
sourceLabels: lt.dataset.sourceLabels ? lt.dataset.sourceLabels.split(',').filter(Boolean) : [],
targetLabels: lt.dataset.targetLabels ? lt.dataset.targetLabels.split(',').filter(Boolean) : []
};
});
}
setupEventListeners() {
// Link type selection
const linkTypeRadios = this.container.querySelectorAll('.span-link-type-radio');
linkTypeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
this.currentLinkType = e.target.value;
this.enterLinkMode();
this.updateUI();
});
});
// Create button
if (this.createButton) {
this.createButton.addEventListener('click', () => this.createLink());
}
// Clear button - exits link mode entirely so user can create new spans
if (this.clearButton) {
this.clearButton.addEventListener('click', () => this.exitLinkMode());
}
// Escape key exits link mode
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isLinkMode) {
e.preventDefault();
this.exitLinkMode();
}
});
// Show arcs toggle
if (this.showArcsCheckbox) {
this.showArcsCheckbox.addEventListener('change', (e) => {
this.toggleArcsVisibility(e.target.checked);
});
}
// Listen for span clicks when in link mode - use capture phase
document.addEventListener('click', (e) => {
if (!this.isLinkMode) return;
console.log('[SpanLinkManager] Click detected in link mode, target:', e.target.className);
// Check if clicked on a span overlay or any element inside it
// This handles clicks on .span-highlight-segment, .span-label, .span-controls, etc.
let spanOverlay = e.target.closest('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight');
// Also check if we clicked on a segment inside an overlay
if (!spanOverlay) {
const segment = e.target.closest('.span-highlight-segment');
if (segment) {
spanOverlay = segment.closest('.span-overlay-pure, .span-overlay, .span-overlay-ai');
}
}
if (spanOverlay && spanOverlay.dataset.annotationId) {
console.log('[SpanLinkManager] Found span overlay:', spanOverlay.className,
'annotationId:', spanOverlay.dataset.annotationId,
'label:', spanOverlay.dataset.label,
'start:', spanOverlay.dataset.start,
'end:', spanOverlay.dataset.end);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.handleSpanClick(spanOverlay);
} else {
console.log('[SpanLinkManager] No valid span overlay found at click target');
}
}, true);
// Also listen on mousedown to prevent text selection while in link mode
document.addEventListener('mousedown', (e) => {
if (!this.isLinkMode) return;
const spanOverlay = e.target.closest('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight, .span-highlight-segment');
if (spanOverlay) {
e.preventDefault();
}
}, true);
}
createArcsContainer() {
console.log('[SpanLinkManager] createArcsContainer() called');
// Check if arc visualization is enabled
const showArcs = this.container.dataset.showArcs !== 'false';
console.log('[SpanLinkManager] showArcs:', showArcs);
if (!showArcs) {
console.log('[SpanLinkManager] Arc visualization disabled');
return;
}
// Find the instance text container to place arcs relative to it
const instanceText = document.getElementById('instance-text');
console.log('[SpanLinkManager] instanceText element:', instanceText);
if (!instanceText) {
console.error('[SpanLinkManager] No instance-text element found!');
return;
}
// Check if wrapper structure already exists (e.g., from previous initialization)
const existingWrapper = instanceText.querySelector('.span-link-text-wrapper');
if (existingWrapper) {
console.log('[SpanLinkManager] Wrapper structure already exists, reusing');
this.textWrapper = existingWrapper;
this.arcSpacer = instanceText.querySelector('.span-link-arc-spacer');
this.arcsContainer = instanceText.querySelector('.span-link-arcs-container');
return;
}
// Store configuration for later use
this.arcPosition = this.container.dataset.arcPosition || 'above';
this.multiLineMode = this.container.dataset.multiLineMode || 'bracket';
this.instanceText = instanceText;
if (this.arcPosition === 'above') {
if (this.multiLineMode === 'single_line') {
// Single-line mode: display text on one line with horizontal scroll
instanceText.style.whiteSpace = 'nowrap';
instanceText.style.overflowX = 'auto';
instanceText.classList.add('dependency-single-line-mode');
} else {
// Bracket mode: wrapped text with bracket-style arcs for multi-line
instanceText.classList.add('dependency-bracket-mode');
}
}
// Create a wrapper structure for reliable arc positioning:
// 1. Arc spacer div (takes up vertical space for arcs)
// 2. Text wrapper (contains the actual text content)
// 3. Arc SVG overlay (positioned absolutely over the spacer)
// Wrap existing content in a text wrapper
this.textWrapper = document.createElement('div');
this.textWrapper.className = 'span-link-text-wrapper';
this.textWrapper.style.cssText = 'position: relative;';
// Move all existing children into the wrapper
while (instanceText.firstChild) {
this.textWrapper.appendChild(instanceText.firstChild);
}
// Create spacer div for arc area (will be sized dynamically)
this.arcSpacer = document.createElement('div');
this.arcSpacer.className = 'span-link-arc-spacer';
this.arcSpacer.style.cssText = `
position: relative;
width: 100%;
height: 100px;
min-height: 100px;
`;
// Create SVG container for arcs - inside the spacer
this.arcsContainer = document.createElement('div');
this.arcsContainer.id = `${this.schemaName}_arcs`;
this.arcsContainer.className = 'span-link-arcs-container';
this.arcsContainer.style.cssText = `
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
z-index: 100;
`;
// Assemble the structure
this.arcSpacer.appendChild(this.arcsContainer);
instanceText.appendChild(this.arcSpacer);
instanceText.appendChild(this.textWrapper);
console.log('[SpanLinkManager] Created arc container structure with spacer');
}
/**
* Dynamically update the arc spacer height based on required arc height
*/
updateArcSpacerHeight(requiredHeight) {
if (!this.arcSpacer) return;
// Add margin for labels (25px) and buffer (15px)
const totalHeight = requiredHeight + 40;
console.log(`[SpanLinkManager] Setting arc spacer height: ${totalHeight}px (arc height: ${requiredHeight}px)`);
this.arcSpacer.style.height = `${totalHeight}px`;
this.arcSpacer.style.minHeight = `${totalHeight}px`;
}
enterLinkMode() {
this.isLinkMode = true;
this.clearSelection();
// Get the color for the current link type
const linkColor = this.linkTypeConfig[this.currentLinkType]?.color || '#6E56CF';
// Add visual indicator that link mode is active
this.container.classList.add('link-mode-active');
// Add body class to indicate link mode is active (used to disable span annotation)
document.body.classList.add('span-link-mode-active');
// Set the link color as a CSS variable on the body for use in selection styles
document.body.style.setProperty('--current-link-color', linkColor);
// Enable pointer events and highlight clickable spans
const spanOverlays = document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight');
console.log(`[SpanLinkManager] Found ${spanOverlays.length} span overlays to make selectable`);
spanOverlays.forEach(overlay => {
overlay.classList.add('link-selectable');
// Set the link color on each overlay for CSS to use
overlay.style.setProperty('--current-link-color', linkColor);
// Enable pointer events so we can click on them
overlay.style.pointerEvents = 'auto';
overlay.style.cursor = 'pointer';
});
// Also enable pointer events on highlight segments inside overlays
const segments = document.querySelectorAll('.span-highlight-segment');
segments.forEach(segment => {
segment.style.pointerEvents = 'auto';
segment.style.cursor = 'pointer';
});
console.log(`[SpanLinkManager] Entered link mode with type: ${this.currentLinkType}`);
}
exitLinkMode() {
this.isLinkMode = false;
this.container.classList.remove('link-mode-active');
// Remove body class and CSS variable
document.body.classList.remove('span-link-mode-active');
document.body.style.removeProperty('--current-link-color');
// Remove visual indicators and reset pointer events
const spanOverlays = document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight');
spanOverlays.forEach(overlay => {
overlay.classList.remove('link-selectable');
overlay.classList.remove('link-selected');
// Reset pointer events to original state
overlay.style.pointerEvents = 'none';
overlay.style.cursor = '';
// Remove color variable
overlay.style.removeProperty('--current-link-color');
});
// Reset pointer events on segments
const segments = document.querySelectorAll('.span-highlight-segment');
segments.forEach(segment => {
segment.style.pointerEvents = 'none';
segment.style.cursor = '';
});
// Deselect radio
const linkTypeRadios = this.container.querySelectorAll('.span-link-type-radio');
linkTypeRadios.forEach(radio => radio.checked = false);
this.currentLinkType = null;
this.clearSelection();
}
/**
* Extract the actual text content for a span using its start/end offsets
*/
getSpanText(spanOverlay) {
const start = parseInt(spanOverlay.dataset.start);
const end = parseInt(spanOverlay.dataset.end);
if (isNaN(start) || isNaN(end)) {
console.warn('Span overlay missing start/end offsets');
return '(unknown)';
}
// Get the original text - span-core.js stores it in #text-content's data-original-text
const textContent = document.getElementById('text-content');
if (textContent && textContent.hasAttribute('data-original-text')) {
const originalText = textContent.getAttribute('data-original-text');
// Strip HTML tags and normalize whitespace like span-core does
const cleanText = originalText.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
console.log(`[SpanLinkManager] getSpanText: start=${start}, end=${end}, text="${cleanText.substring(start, end)}"`);
return cleanText.substring(start, end);
}
// Fallback: try to get from spanManager if available
if (window.spanManager && typeof window.spanManager.getCanonicalText === 'function') {
const canonicalText = window.spanManager.getCanonicalText();
console.log(`[SpanLinkManager] getSpanText from spanManager: start=${start}, end=${end}, text="${canonicalText.substring(start, end)}"`);
return canonicalText.substring(start, end);
}
// Last resort fallback
const instanceText = document.getElementById('instance-text');
if (instanceText) {
const rawText = instanceText.textContent.replace(/\s+/g, ' ').trim();
console.log(`[SpanLinkManager] getSpanText fallback: start=${start}, end=${end}, text="${rawText.substring(start, end)}"`);
return rawText.substring(start, end);
}
return '(unknown)';
}
handleSpanClick(spanOverlay) {
const spanId = spanOverlay.dataset.spanId || spanOverlay.dataset.annotationId;
const spanLabel = spanOverlay.dataset.label;
if (!spanId) {
console.warn('Span overlay has no span ID');
return;
}
// Get the actual span text
const spanText = this.getSpanText(spanOverlay);
console.log(`[SpanLinkManager] Span clicked: ${spanLabel} = "${spanText}"`);
// Validate span label constraints
if (this.currentLinkType && this.linkTypeConfig[this.currentLinkType]) {
const config = this.linkTypeConfig[this.currentLinkType];
const isFirst = this.selectedSpans.length === 0;
// Check source label constraint for first span (directed links)
if (isFirst && config.directed && config.sourceLabels.length > 0) {
if (!config.sourceLabels.includes(spanLabel)) {
console.log(`Span label ${spanLabel} not allowed as source`);
this.showConstraintError(`Source must be: ${config.sourceLabels.join(', ')}`);
return;
}
}
// Check target label constraint for subsequent spans (directed links)
if (!isFirst && config.directed && config.targetLabels.length > 0) {
if (!config.targetLabels.includes(spanLabel)) {
console.log(`Span label ${spanLabel} not allowed as target`);
this.showConstraintError(`Target must be: ${config.targetLabels.join(', ')}`);
return;
}
}
}
// Toggle selection
const existingIndex = this.selectedSpans.findIndex(s => s.id === spanId);
if (existingIndex >= 0) {
// Deselect
this.selectedSpans.splice(existingIndex, 1);
spanOverlay.classList.remove('link-selected');
} else {
// Check max spans limit
const maxSpans = this.linkTypeConfig[this.currentLinkType]?.maxSpans || 2;
if (this.selectedSpans.length >= maxSpans) {
this.showConstraintError(`Maximum ${maxSpans} spans for this link type`);
return;
}
// Select - use extracted text, not textContent
this.selectedSpans.push({
id: spanId,
label: spanLabel,
text: spanText,
element: spanOverlay
});
spanOverlay.classList.add('link-selected');
}
this.updateUI();
}
showConstraintError(message) {
// Show a temporary error message
const existingError = this.container.querySelector('.constraint-error');
if (existingError) existingError.remove();
const errorDiv = document.createElement('div');
errorDiv.className = 'constraint-error';
errorDiv.textContent = message;
this.container.querySelector('.span-link-selection').appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 3000);
}
selectSpanForLink(spanId) {
const spanOverlay = document.querySelector(
`.span-overlay-pure[data-annotation-id="${spanId}"], ` +
`.span-overlay[data-annotation-id="${spanId}"], ` +
`.span-highlight[data-annotation-id="${spanId}"]`
);
if (spanOverlay) {
this.handleSpanClick(spanOverlay);
}
}
clearSelection() {
this.selectedSpans.forEach(span => {
if (span.element) {
span.element.classList.remove('link-selected');
}
});
this.selectedSpans = [];
this.updateUI();
}
async createLink() {
if (!this.currentLinkType || this.selectedSpans.length < 2) {
console.warn('Cannot create link: need link type and at least 2 spans');
return;
}
const config = this.linkTypeConfig[this.currentLinkType] || {};
// Extract span positions for repair matching on reload
const spanPositions = this.selectedSpans.map(s => {
if (s.element) {
return {
start: parseInt(s.element.dataset.start),
end: parseInt(s.element.dataset.end)
};
}
return null;
});
const link = {
id: `link_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
schema: this.schemaName,
link_type: this.currentLinkType,
span_ids: this.selectedSpans.map(s => s.id),
direction: config.directed ? 'directed' : 'undirected',
properties: {
color: config.color,
span_labels: this.selectedSpans.map(s => s.label),
span_texts: this.selectedSpans.map(s => s.text.substring(0, 30)),
span_positions: spanPositions // For fallback matching when span IDs change
}
};
// Add to local list
this.links.push(link);
// Save to backend
await this.saveLink(link);
// Update UI
this.updateLinkList();
this.renderArcs();
this.clearSelection();
console.log('Created link:', link);
}
async saveLink(link) {
const instanceId = document.getElementById('instance_id')?.value;
if (!instanceId) {
console.error('No instance ID found');
return;
}
try {
const response = await fetch('/updateinstance', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
instance_id: instanceId,
annotations: {}, // Required for frontend format detection
link_annotations: [link]
})
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
console.log('Link saved successfully');
} catch (error) {
console.error('Error saving link:', error);
}
}
async deleteLink(linkId) {
const instanceId = document.getElementById('instance_id')?.value;
if (!instanceId) return;
// Remove from local list
this.links = this.links.filter(l => l.id !== linkId);
try {
const response = await fetch(`/api/links/${instanceId}/${linkId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
console.log('Link deleted successfully');
} catch (error) {
console.error('Error deleting link:', error);
}
// Update UI
this.updateLinkList();
this.renderArcs();
}
async loadExistingLinks() {
const instanceId = document.getElementById('instance_id')?.value;
console.log('[SpanLinkManager] loadExistingLinks called, instanceId:', instanceId);
if (!instanceId) {
console.warn('[SpanLinkManager] No instance ID, skipping load');
return;
}
try {
console.log('[SpanLinkManager] Fetching links from API...');
const response = await fetch(`/api/links/${instanceId}`);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
const data = await response.json();
this.links = data.links || [];
// Update UI
this.updateLinkList();
console.log(`[SpanLinkManager] Loaded ${this.links.length} existing links:`, this.links);
// Render arcs after waiting for spans to be created
// Span overlays are created asynchronously by span-manager.js
if (this.links.length > 0) {
console.log('[SpanLinkManager] Starting waitForSpansAndRender...');
this.waitForSpansAndRender();
} else {
console.log('[SpanLinkManager] No links to render');
}
} catch (error) {
console.error('[SpanLinkManager] Error loading links:', error);
}
}
/**
* Try to repair orphaned link span_ids by matching with current spans.
* This handles the case where spans were recreated with new UUIDs.
* Uses span positions (start/end) and labels for precise matching.
*/
repairOrphanedLinks() {
const currentSpanIds = new Set(Object.keys(this.getSpanPositions()));
let repaired = false;
this.links.forEach(link => {
const orphanedIds = link.span_ids.filter(id => !currentSpanIds.has(id));
if (orphanedIds.length > 0) {
console.log('[SpanLinkManager] Found orphaned span IDs in link:', orphanedIds);
// Get all current spans from DOM
const allOverlays = document.querySelectorAll(
'.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight'
);
// Build lookup by position (start, end, label) for precise matching
const spansByPosition = new Map();
allOverlays.forEach(overlay => {
const start = overlay.dataset.start;
const end = overlay.dataset.end;
const label = overlay.dataset.label;
const id = overlay.dataset.annotationId || overlay.dataset.spanId;
if (start && end && label && id) {
const key = `${start}_${end}_${label}`;
spansByPosition.set(key, id);
}
});
// Get stored span metadata
const spanPositions = link.properties?.span_positions || [];
const spanLabels = link.properties?.span_labels || [];
const repairs = [];
orphanedIds.forEach(orphanedId => {
const orphanedIdx = link.span_ids.indexOf(orphanedId);
const position = spanPositions[orphanedIdx];
const label = spanLabels[orphanedIdx];
// Try to match by position and label first (most precise)
if (position && label) {
const key = `${position.start}_${position.end}_${label}`;
const newId = spansByPosition.get(key);
if (newId && !link.span_ids.includes(newId)) {
console.log(`[SpanLinkManager] Repairing by position: ${orphanedId} -> ${newId} (${key})`);
repairs.push({ old: orphanedId, new: newId });
return;
}
}
// Fallback: match by label only if position matching fails
if (label) {
for (const overlay of allOverlays) {
const overlayId = overlay.dataset.annotationId || overlay.dataset.spanId;
const overlayLabel = overlay.dataset.label;
if (overlayLabel === label &&
!link.span_ids.includes(overlayId) &&
!repairs.some(r => r.new === overlayId)) {
console.log(`[SpanLinkManager] Repairing by label fallback: ${orphanedId} -> ${overlayId} (label: ${label})`);
repairs.push({ old: orphanedId, new: overlayId });
break;
}
}
}
});
// Apply repairs
repairs.forEach(repair => {
const idx = link.span_ids.indexOf(repair.old);
if (idx !== -1) {
link.span_ids[idx] = repair.new;
repaired = true;
}
});
}
});
if (repaired) {
console.log('[SpanLinkManager] Links repaired, re-rendering');
}
return repaired;
}
/**
* Wait for span overlays to exist, then render arcs.
* This handles the timing issue where span-manager.js creates spans asynchronously.
*/
waitForSpansAndRender() {
console.log('[SpanLinkManager] waitForSpansAndRender() called');
if (this.links.length === 0) {
console.log('[SpanLinkManager] No links to render');
return;
}
// Get the span IDs we need to render
const neededSpanIds = new Set();
this.links.forEach(link => {
link.span_ids.forEach(id => neededSpanIds.add(id));
});
console.log('[SpanLinkManager] Waiting for spans:', [...neededSpanIds]);
// Check if spans exist
const checkSpans = () => {
const positions = this.getSpanPositions();
const foundIds = Object.keys(positions);
const allFound = [...neededSpanIds].every(id => foundIds.includes(id));
console.log(`[SpanLinkManager] Span check: found ${foundIds.length} spans, need ${neededSpanIds.size}, allFound=${allFound}`);
console.log('[SpanLinkManager] Found span IDs:', foundIds);
console.log('[SpanLinkManager] Needed span IDs:', [...neededSpanIds]);
if (allFound || foundIds.length > 0) {
console.log('[SpanLinkManager] Spans found, rendering arcs');
// Try to repair orphaned links before rendering
this.repairOrphanedLinks();
this.renderArcs();
return true;
}
return false;
};
// Try immediately
console.log('[SpanLinkManager] Attempting immediate span check...');
if (checkSpans()) {
console.log('[SpanLinkManager] Immediate check succeeded');
return;
}
console.log('[SpanLinkManager] Immediate check failed, starting retry loop');
// Retry with delays (spans might be loading async)
const delays = [100, 250, 500, 1000, 2000];
let attempt = 0;
const retry = () => {
if (attempt >= delays.length) {
console.warn('[SpanLinkManager] Gave up waiting for spans after all retries');
console.log('[SpanLinkManager] Final span overlay count:', document.querySelectorAll('.span-overlay-pure, .span-overlay').length);
// Render anyway to show the link list even if arcs can't be drawn
this.renderArcs();
return;
}
setTimeout(() => {
console.log(`[SpanLinkManager] Retry attempt ${attempt + 1}/${delays.length} after ${delays[attempt]}ms`);
if (!checkSpans()) {
attempt++;
retry();
} else {
console.log('[SpanLinkManager] Retry succeeded on attempt', attempt + 1);
}
}, delays[attempt]);
};
retry();
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Truncate text with ellipsis
*/
truncateText(text, maxLength = 25) {
if (!text) return '';
const escaped = this.escapeHtml(text);
if (text.length <= maxLength) return escaped;
return this.escapeHtml(text.substring(0, maxLength)) + '...';
}
updateUI() {
// Update selected spans display
if (this.selectedSpansDisplay) {
if (this.selectedSpans.length === 0) {
const instruction = this.isLinkMode
? '<p class="link-mode-instruction">Click on <strong>highlighted spans</strong> to select them for linking. Press <kbd>Esc</kbd> or click "Exit Link Mode" to create new spans.</p>'
: '<p class="no-selection-message">Select a link type to start linking spans</p>';
this.selectedSpansDisplay.innerHTML = instruction;
} else {
const spansHtml = this.selectedSpans.map((span, index) => {
const config = this.linkTypeConfig[this.currentLinkType] || {};
let roleLabel = '';
if (config.directed) {
roleLabel = index === 0 ? '<span class="span-role">(source)</span>' : '<span class="span-role">(target)</span>';
}
const displayText = this.truncateText(span.text, 25);
return `
<div class="selected-span-item" data-span-id="${span.id}">
<span class="selected-span-label">${this.escapeHtml(span.label)}</span>
<span class="selected-span-text">"${displayText}"</span>
${roleLabel}
<button type="button" class="remove-span-btn" onclick="window.spanLinkManagers['${this.schemaName}'].deselectSpan('${span.id}')">&times;</button>
</div>
`;
}).join('');
this.selectedSpansDisplay.innerHTML = spansHtml;
}
}
// Update create button state
if (this.createButton) {
const minSpans = 2;
this.createButton.disabled = !this.currentLinkType || this.selectedSpans.length < minSpans;
}
// Update hidden input with link data
if (this.linkDataInput) {
this.linkDataInput.value = JSON.stringify(this.links);
}
}
deselectSpan(spanId) {
const span = this.selectedSpans.find(s => s.id === spanId);
if (span && span.element) {
span.element.classList.remove('link-selected');
}
this.selectedSpans = this.selectedSpans.filter(s => s.id !== spanId);
this.updateUI();
}
updateLinkList() {
if (!this.linkList) return;
if (this.links.length === 0) {
this.linkList.innerHTML = '<p class="no-links-message">No links created yet</p>';
return;
}
const linksHtml = this.links.map(link => {
const config = this.linkTypeConfig[link.link_type] || {};
const spanTexts = link.properties?.span_texts || link.span_ids;
const directionIcon = config.directed ? '→' : '↔';
return `
<div class="link-item" data-link-id="${link.id}" style="--link-color: ${config.color || '#dc2626'}">
<div class="link-info">
<span class="link-type-badge" style="background-color: ${config.color || '#dc2626'}">${link.link_type}</span>
<span class="link-spans">
${spanTexts.join(` <span class="link-direction">${directionIcon}</span> `)}
</span>
</div>
<button type="button" class="delete-link-btn" onclick="window.spanLinkManagers['${this.schemaName}'].deleteLink('${link.id}')"
title="Delete link">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`;
}).join('');
this.linkList.innerHTML = linksHtml;
}
renderArcs() {
console.log('[SpanLinkManager] renderArcs() called');
console.log('[SpanLinkManager] arcsContainer exists:', !!this.arcsContainer);
console.log('[SpanLinkManager] arcSpacer exists:', !!this.arcSpacer);
console.log('[SpanLinkManager] textWrapper exists:', !!this.textWrapper);
console.log('[SpanLinkManager] links count:', this.links.length);
if (!this.arcsContainer) {
console.error('[SpanLinkManager] No arcsContainer! createArcsContainer may have failed');
return;
}
// Clear existing arcs
this.arcsContainer.innerHTML = '';
if (this.links.length === 0) {
// Reset to minimum spacer height when no links
console.log('[SpanLinkManager] No links, resetting spacer height');
this.updateArcSpacerHeight(60);
return;
}
// Get span positions relative to text wrapper
const spanPositions = this.getSpanPositions();
console.log('[SpanLinkManager] Span positions:', spanPositions);
console.log('[SpanLinkManager] Links to render:', this.links);
// First pass: calculate maximum arc height needed
let maxArcHeight = 60; // minimum
this.links.forEach(link => {
const spanIds = link.span_ids;
if (spanIds.length >= 2) {
const pos1 = spanPositions[spanIds[0]];
const pos2 = spanPositions[spanIds[1]];
if (pos1 && pos2) {
const x1 = pos1.x + pos1.width / 2;
const x2 = pos2.x + pos2.width / 2;
// Arc height is proportional to horizontal distance
const arcHeight = Math.max(40, Math.min(Math.abs(x2 - x1) / 2, 120));
maxArcHeight = Math.max(maxArcHeight, arcHeight);
}
}
});
// Update arc spacer height
this.updateArcSpacerHeight(maxArcHeight);
// Get spacer height for positioning arcs from bottom of spacer
const spacerHeight = parseInt(this.arcSpacer.style.height) || (maxArcHeight + 40);
// Create SVG sized to fit the spacer
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', spacerHeight);
svg.style.cssText = 'position: absolute; bottom: 0; left: 0; pointer-events: none; overflow: visible;';
// Add arrow marker definition
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.innerHTML = `
<marker id="link-arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
</marker>
`;
svg.appendChild(defs);
// Draw arcs for each link
// Arcs start at bottom of spacer (y = spacerHeight) and curve upward
this.links.forEach(link => {
const config = this.linkTypeConfig[link.link_type] || {};
const color = config.color || '#dc2626';
const spanIds = link.span_ids;
if (spanIds.length < 2) return;
// Binary link - draw arc
if (spanIds.length === 2) {
const pos1 = spanPositions[spanIds[0]];
const pos2 = spanPositions[spanIds[1]];
console.log('[SpanLinkManager] Drawing arc between:', spanIds[0], pos1, 'and', spanIds[1], pos2);
if (!pos1 || !pos2) {
console.log('[SpanLinkManager] Missing position, skipping arc');
return;
}
// X positions from span centers
const x1 = pos1.x + pos1.width / 2;
const x2 = pos2.x + pos2.width / 2;
// Y positions: arcs start at bottom of spacer and curve up
const anchorY = spacerHeight; // Bottom of spacer where arcs connect to text
console.log('[SpanLinkManager] Arc coordinates: x1=', x1, 'x2=', x2, 'anchorY=', anchorY, 'spacerHeight=', spacerHeight);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// Check if spans are on different lines (Y positions differ significantly)
const sameLineThreshold = 10; // pixels
const isMultiLine = Math.abs(pos2.y - pos1.y) > sameLineThreshold;
// Get multi-line mode from config
const multiLineMode = this.container.dataset.multiLineMode || 'bracket';
let pathD;
let labelY;
let labelX = (x1 + x2) / 2;
if (isMultiLine && multiLineMode === 'bracket') {
// Bracket-style arc for multi-line: goes up, across at top, then down
const cornerRadius = 8;
const topY = 20; // Position near top of spacer
const goingRight = x2 > x1;
if (goingRight) {
pathD = `M ${x1} ${anchorY}
L ${x1} ${topY + cornerRadius}
Q ${x1} ${topY}, ${x1 + cornerRadius} ${topY}
L ${x2 - cornerRadius} ${topY}
Q ${x2} ${topY}, ${x2} ${topY + cornerRadius}
L ${x2} ${anchorY}`;
} else {
pathD = `M ${x1} ${anchorY}
L ${x1} ${topY + cornerRadius}
Q ${x1} ${topY}, ${x1 - cornerRadius} ${topY}
L ${x2 + cornerRadius} ${topY}
Q ${x2} ${topY}, ${x2} ${topY + cornerRadius}
L ${x2} ${anchorY}`;
}
labelY = topY - 5;
labelX = (x1 + x2) / 2;
} else {
// Same-line arc: simple quadratic bezier curve
const midX = (x1 + x2) / 2;
const arcHeight = Math.max(40, Math.min(Math.abs(x2 - x1) / 2, 120));
const controlY = anchorY - arcHeight; // Control point above anchor
pathD = `M ${x1} ${anchorY} Q ${midX} ${controlY} ${x2} ${anchorY}`;
labelY = controlY - 5;
labelX = midX;
}
path.setAttribute('d', pathD);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', color);
path.setAttribute('stroke-width', '2.5');
path.setAttribute('class', 'span-link-arc');
path.dataset.linkId = link.id;
if (config.directed) {
path.setAttribute('marker-end', 'url(#link-arrowhead)');
path.style.color = color;
}
svg.appendChild(path);
// Add label on arc if showLabels is enabled
const showLabels = this.container.dataset.showLabels !== 'false';
if (showLabels && link.link_type) {
// Create a unique path ID for textPath reference
const pathId = `arc-path-${link.id}`;
path.setAttribute('id', pathId);
// labelX and labelY were already calculated above for both modes
// Create text element for the label
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', labelX);
text.setAttribute('y', labelY);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('class', 'span-link-label');
text.setAttribute('fill', color);
text.style.fontSize = '11px';
text.style.fontWeight = '500';
text.style.pointerEvents = 'none';
text.textContent = link.link_type;
// Add background rect for readability
const bbox = { width: link.link_type.length * 7 + 6, height: 14 };
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('x', labelX - bbox.width / 2);
bgRect.setAttribute('y', labelY - 10);
bgRect.setAttribute('width', bbox.width);
bgRect.setAttribute('height', bbox.height);
bgRect.setAttribute('fill', 'white');
bgRect.setAttribute('rx', '3');
bgRect.setAttribute('class', 'span-link-label-bg');
bgRect.style.pointerEvents = 'none';
svg.appendChild(bgRect);
svg.appendChild(text);
}
} else {
// N-ary link - connect to central point
const validPositions = spanIds.map(id => spanPositions[id]).filter(Boolean);
if (validPositions.length < 2) return;
const centerX = validPositions.reduce((sum, p) => sum + p.x + p.width / 2, 0) / validPositions.length;
const centerY = spacerHeight / 2; // Center of spacer
spanIds.forEach(spanId => {
const pos = spanPositions[spanId];
if (!pos) return;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', pos.x + pos.width / 2);
line.setAttribute('y1', spacerHeight); // Bottom of spacer
line.setAttribute('x2', centerX);
line.setAttribute('y2', centerY);
line.setAttribute('stroke', color);
line.setAttribute('stroke-width', '2');
line.setAttribute('class', 'span-link-arc');
line.dataset.linkId = link.id;
svg.appendChild(line);
});
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', centerX);
circle.setAttribute('cy', centerY);
circle.setAttribute('r', '5');
circle.setAttribute('fill', color);
circle.setAttribute('class', 'span-link-node');
circle.dataset.linkId = link.id;
svg.appendChild(circle);
}
});
this.arcsContainer.appendChild(svg);
}
getSpanPositions() {
const positions = {};
// Get positions relative to text wrapper (not instance-text)
const referenceContainer = this.textWrapper || document.getElementById('instance-text');
console.log('[SpanLinkManager] getSpanPositions: referenceContainer:', referenceContainer?.className || referenceContainer?.id);
if (!referenceContainer) {
console.warn('[SpanLinkManager] getSpanPositions: No reference container!');
return positions;
}
const containerRect = referenceContainer.getBoundingClientRect();
console.log('[SpanLinkManager] getSpanPositions: containerRect:', containerRect);
// Count span overlays
const allOverlays = document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight');
console.log('[SpanLinkManager] getSpanPositions: Total overlays found:', allOverlays.length);
document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight').forEach(overlay => {
const spanId = overlay.dataset.spanId || overlay.dataset.annotationId;
if (!spanId) return;
// The overlay container may have zero dimensions - get bounds from highlight segments
const segments = overlay.querySelectorAll('.span-highlight-segment');
let rect;
if (segments.length > 0) {
// Calculate bounding box from all segments (handles multi-line spans)
let minLeft = Infinity, minTop = Infinity, maxRight = -Infinity, maxBottom = -Infinity;
segments.forEach(segment => {
const segRect = segment.getBoundingClientRect();
if (segRect.width > 0 && segRect.height > 0) {
minLeft = Math.min(minLeft, segRect.left);
minTop = Math.min(minTop, segRect.top);
maxRight = Math.max(maxRight, segRect.right);
maxBottom = Math.max(maxBottom, segRect.bottom);
}
});
if (minLeft !== Infinity) {
rect = {
left: minLeft,
top: minTop,
right: maxRight,
bottom: maxBottom,
width: maxRight - minLeft,
height: maxBottom - minTop
};
}
}
// Fallback to overlay bounds if no segments found
if (!rect) {
rect = overlay.getBoundingClientRect();
}
if (rect.width > 0 && rect.height > 0) {
positions[spanId] = {
x: rect.left - containerRect.left,
y: rect.top - containerRect.top,
width: rect.width,
height: rect.height
};
}
});
return positions;
}
toggleArcsVisibility(visible) {
if (this.arcsContainer) {
this.arcsContainer.style.display = visible ? 'block' : 'none';
}
}
}
// Global registry of span link managers
window.spanLinkManagers = window.spanLinkManagers || {};
// Initialize span link managers when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
const linkContainers = document.querySelectorAll('.span-link-container');
linkContainers.forEach(container => {
const schemaName = container.id;
const spanSchemaName = container.dataset.spanSchema;
if (schemaName && spanSchemaName) {
window.spanLinkManagers[schemaName] = new SpanLinkManager(schemaName, spanSchemaName);
}
});
});
// Re-render arcs on window resize
window.addEventListener('resize', function() {
Object.values(window.spanLinkManagers || {}).forEach(manager => {
manager.renderArcs();
});
});