| |
| |
| |
| |
| |
| |
|
|
| class SpanLinkManager { |
| constructor(schemaName, spanSchemaName) { |
| this.schemaName = schemaName; |
| this.spanSchemaName = spanSchemaName; |
| this.selectedSpans = []; |
| this.currentLinkType = null; |
| this.links = []; |
| this.isLinkMode = false; |
| this.linkTypeConfig = {}; |
|
|
| |
| 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`); |
|
|
| |
| 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; |
| } |
|
|
| |
| this.parseLinkTypeConfigs(); |
| console.log('[SpanLinkManager] Link type configs:', this.linkTypeConfig); |
|
|
| |
| this.setupEventListeners(); |
|
|
| |
| this.createArcsContainer(); |
|
|
| |
| this.setupSpanObserver(); |
|
|
| |
| 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 |
| }); |
| } |
|
|
| |
| |
| |
| |
| setupSpanObserver() { |
| const targetNode = this.textWrapper || document.getElementById('instance-text'); |
| if (!targetNode) return; |
|
|
| |
| 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) => { |
| |
| const hasSpanChanges = mutations.some(mutation => { |
| |
| 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; |
| } |
| } |
| } |
| |
| 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() { |
| |
| 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(); |
| }); |
| }); |
|
|
| |
| if (this.createButton) { |
| this.createButton.addEventListener('click', () => this.createLink()); |
| } |
|
|
| |
| if (this.clearButton) { |
| this.clearButton.addEventListener('click', () => this.exitLinkMode()); |
| } |
|
|
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape' && this.isLinkMode) { |
| e.preventDefault(); |
| this.exitLinkMode(); |
| } |
| }); |
|
|
| |
| if (this.showArcsCheckbox) { |
| this.showArcsCheckbox.addEventListener('change', (e) => { |
| this.toggleArcsVisibility(e.target.checked); |
| }); |
| } |
|
|
| |
| document.addEventListener('click', (e) => { |
| if (!this.isLinkMode) return; |
|
|
| console.log('[SpanLinkManager] Click detected in link mode, target:', e.target.className); |
|
|
| |
| |
| let spanOverlay = e.target.closest('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight'); |
|
|
| |
| 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); |
|
|
| |
| 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'); |
|
|
| |
| const showArcs = this.container.dataset.showArcs !== 'false'; |
| console.log('[SpanLinkManager] showArcs:', showArcs); |
| if (!showArcs) { |
| console.log('[SpanLinkManager] Arc visualization disabled'); |
| return; |
| } |
|
|
| |
| const instanceText = document.getElementById('instance-text'); |
| console.log('[SpanLinkManager] instanceText element:', instanceText); |
| if (!instanceText) { |
| console.error('[SpanLinkManager] No instance-text element found!'); |
| return; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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') { |
| |
| instanceText.style.whiteSpace = 'nowrap'; |
| instanceText.style.overflowX = 'auto'; |
| instanceText.classList.add('dependency-single-line-mode'); |
| } else { |
| |
| instanceText.classList.add('dependency-bracket-mode'); |
| } |
| } |
|
|
| |
| |
| |
| |
|
|
| |
| this.textWrapper = document.createElement('div'); |
| this.textWrapper.className = 'span-link-text-wrapper'; |
| this.textWrapper.style.cssText = 'position: relative;'; |
|
|
| |
| while (instanceText.firstChild) { |
| this.textWrapper.appendChild(instanceText.firstChild); |
| } |
|
|
| |
| 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; |
| `; |
|
|
| |
| 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; |
| `; |
|
|
| |
| this.arcSpacer.appendChild(this.arcsContainer); |
| instanceText.appendChild(this.arcSpacer); |
| instanceText.appendChild(this.textWrapper); |
|
|
| console.log('[SpanLinkManager] Created arc container structure with spacer'); |
| } |
|
|
| |
| |
| |
| updateArcSpacerHeight(requiredHeight) { |
| if (!this.arcSpacer) return; |
|
|
| |
| 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(); |
|
|
| |
| const linkColor = this.linkTypeConfig[this.currentLinkType]?.color || '#6E56CF'; |
|
|
| |
| this.container.classList.add('link-mode-active'); |
|
|
| |
| document.body.classList.add('span-link-mode-active'); |
|
|
| |
| document.body.style.setProperty('--current-link-color', linkColor); |
|
|
| |
| 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'); |
| |
| overlay.style.setProperty('--current-link-color', linkColor); |
| |
| overlay.style.pointerEvents = 'auto'; |
| overlay.style.cursor = 'pointer'; |
| }); |
|
|
| |
| 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'); |
|
|
| |
| document.body.classList.remove('span-link-mode-active'); |
| document.body.style.removeProperty('--current-link-color'); |
|
|
| |
| 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'); |
| |
| overlay.style.pointerEvents = 'none'; |
| overlay.style.cursor = ''; |
| |
| overlay.style.removeProperty('--current-link-color'); |
| }); |
|
|
| |
| const segments = document.querySelectorAll('.span-highlight-segment'); |
| segments.forEach(segment => { |
| segment.style.pointerEvents = 'none'; |
| segment.style.cursor = ''; |
| }); |
|
|
| |
| const linkTypeRadios = this.container.querySelectorAll('.span-link-type-radio'); |
| linkTypeRadios.forEach(radio => radio.checked = false); |
|
|
| this.currentLinkType = null; |
| this.clearSelection(); |
| } |
|
|
| |
| |
| |
| 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)'; |
| } |
|
|
| |
| const textContent = document.getElementById('text-content'); |
| if (textContent && textContent.hasAttribute('data-original-text')) { |
| const originalText = textContent.getAttribute('data-original-text'); |
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| const spanText = this.getSpanText(spanOverlay); |
| console.log(`[SpanLinkManager] Span clicked: ${spanLabel} = "${spanText}"`); |
|
|
| |
| if (this.currentLinkType && this.linkTypeConfig[this.currentLinkType]) { |
| const config = this.linkTypeConfig[this.currentLinkType]; |
| const isFirst = this.selectedSpans.length === 0; |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| const existingIndex = this.selectedSpans.findIndex(s => s.id === spanId); |
| if (existingIndex >= 0) { |
| |
| this.selectedSpans.splice(existingIndex, 1); |
| spanOverlay.classList.remove('link-selected'); |
| } else { |
| |
| const maxSpans = this.linkTypeConfig[this.currentLinkType]?.maxSpans || 2; |
| if (this.selectedSpans.length >= maxSpans) { |
| this.showConstraintError(`Maximum ${maxSpans} spans for this link type`); |
| return; |
| } |
|
|
| |
| this.selectedSpans.push({ |
| id: spanId, |
| label: spanLabel, |
| text: spanText, |
| element: spanOverlay |
| }); |
| spanOverlay.classList.add('link-selected'); |
| } |
|
|
| this.updateUI(); |
| } |
|
|
| showConstraintError(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] || {}; |
|
|
| |
| 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 |
| } |
| }; |
|
|
| |
| this.links.push(link); |
|
|
| |
| await this.saveLink(link); |
|
|
| |
| 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: {}, |
| 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; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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 || []; |
|
|
| |
| this.updateLinkList(); |
|
|
| console.log(`[SpanLinkManager] Loaded ${this.links.length} existing links:`, this.links); |
|
|
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| const allOverlays = document.querySelectorAll( |
| '.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight' |
| ); |
|
|
| |
| 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); |
| } |
| }); |
|
|
| |
| 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]; |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| 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; |
| } |
| } |
| } |
| }); |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| waitForSpansAndRender() { |
| console.log('[SpanLinkManager] waitForSpansAndRender() called'); |
|
|
| if (this.links.length === 0) { |
| console.log('[SpanLinkManager] No links to render'); |
| return; |
| } |
|
|
| |
| const neededSpanIds = new Set(); |
| this.links.forEach(link => { |
| link.span_ids.forEach(id => neededSpanIds.add(id)); |
| }); |
|
|
| console.log('[SpanLinkManager] Waiting for spans:', [...neededSpanIds]); |
|
|
| |
| 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'); |
| |
| this.repairOrphanedLinks(); |
| this.renderArcs(); |
| return true; |
| } |
| return false; |
| }; |
|
|
| |
| 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'); |
|
|
| |
| 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); |
| |
| 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(); |
| } |
|
|
| |
| |
| |
| escapeHtml(text) { |
| const div = document.createElement('div'); |
| div.textContent = text; |
| return div.innerHTML; |
| } |
|
|
| |
| |
| |
| 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() { |
| |
| 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}')">×</button> |
| </div> |
| `; |
| }).join(''); |
| this.selectedSpansDisplay.innerHTML = spansHtml; |
| } |
| } |
|
|
| |
| if (this.createButton) { |
| const minSpans = 2; |
| this.createButton.disabled = !this.currentLinkType || this.selectedSpans.length < minSpans; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| this.arcsContainer.innerHTML = ''; |
|
|
| if (this.links.length === 0) { |
| |
| console.log('[SpanLinkManager] No links, resetting spacer height'); |
| this.updateArcSpacerHeight(60); |
| return; |
| } |
|
|
| |
| const spanPositions = this.getSpanPositions(); |
| console.log('[SpanLinkManager] Span positions:', spanPositions); |
| console.log('[SpanLinkManager] Links to render:', this.links); |
|
|
| |
| let maxArcHeight = 60; |
| 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; |
| |
| const arcHeight = Math.max(40, Math.min(Math.abs(x2 - x1) / 2, 120)); |
| maxArcHeight = Math.max(maxArcHeight, arcHeight); |
| } |
| } |
| }); |
|
|
| |
| this.updateArcSpacerHeight(maxArcHeight); |
|
|
| |
| const spacerHeight = parseInt(this.arcSpacer.style.height) || (maxArcHeight + 40); |
|
|
| |
| 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;'; |
|
|
| |
| 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); |
|
|
| |
| |
| 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; |
|
|
| |
| 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; |
| } |
|
|
| |
| const x1 = pos1.x + pos1.width / 2; |
| const x2 = pos2.x + pos2.width / 2; |
|
|
| |
| const anchorY = spacerHeight; |
|
|
| console.log('[SpanLinkManager] Arc coordinates: x1=', x1, 'x2=', x2, 'anchorY=', anchorY, 'spacerHeight=', spacerHeight); |
|
|
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); |
|
|
| |
| const sameLineThreshold = 10; |
| const isMultiLine = Math.abs(pos2.y - pos1.y) > sameLineThreshold; |
|
|
| |
| const multiLineMode = this.container.dataset.multiLineMode || 'bracket'; |
|
|
| let pathD; |
| let labelY; |
| let labelX = (x1 + x2) / 2; |
|
|
| if (isMultiLine && multiLineMode === 'bracket') { |
| |
| const cornerRadius = 8; |
| const topY = 20; |
|
|
| 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 { |
| |
| const midX = (x1 + x2) / 2; |
| const arcHeight = Math.max(40, Math.min(Math.abs(x2 - x1) / 2, 120)); |
| const controlY = anchorY - arcHeight; |
|
|
| 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); |
|
|
| |
| const showLabels = this.container.dataset.showLabels !== 'false'; |
| if (showLabels && link.link_type) { |
| |
| const pathId = `arc-path-${link.id}`; |
| path.setAttribute('id', pathId); |
|
|
| |
|
|
| |
| 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; |
|
|
| |
| 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 { |
| |
| 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; |
|
|
| 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); |
| 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 = {}; |
|
|
| |
| 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); |
|
|
| |
| 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; |
|
|
| |
| const segments = overlay.querySelectorAll('.span-highlight-segment'); |
| let rect; |
|
|
| if (segments.length > 0) { |
| |
| 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 |
| }; |
| } |
| } |
|
|
| |
| 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'; |
| } |
| } |
| } |
|
|
| |
| window.spanLinkManagers = window.spanLinkManagers || {}; |
|
|
| |
| 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); |
| } |
| }); |
| }); |
|
|
| |
| window.addEventListener('resize', function() { |
| Object.values(window.spanLinkManagers || {}).forEach(manager => { |
| manager.renderArcs(); |
| }); |
| }); |
|
|