Spaces:
Paused
Paused
| /** | |
| * Event Annotation Manager | |
| * | |
| * Handles the creation, management, and visualization of N-ary events | |
| * with triggers and typed arguments in the Potato annotation platform. | |
| */ | |
| class EventAnnotationManager { | |
| constructor(schemaName, spanSchemaName) { | |
| this.schemaName = schemaName; | |
| this.spanSchemaName = spanSchemaName; | |
| this.events = []; | |
| this.isEventMode = false; | |
| // State machine: idle -> select_type -> select_trigger -> assign_arguments | |
| this.state = 'idle'; | |
| this.currentEventType = null; | |
| this.currentEventConfig = null; | |
| this.triggerSpan = null; | |
| this.arguments = {}; // Maps role -> span data | |
| this.currentRole = null; | |
| // DOM element references | |
| this.container = document.getElementById(schemaName); | |
| this.triggerSection = null; | |
| this.argumentsSection = null; | |
| this.argumentsPanel = null; | |
| this.triggerDisplay = null; | |
| this.eventList = null; | |
| this.createButton = null; | |
| this.cancelButton = null; | |
| this.showArcsCheckbox = null; | |
| this.eventDataInput = null; | |
| // Arc rendering | |
| this.arcsContainer = null; | |
| this.arcSpacer = null; | |
| this.textWrapper = null; | |
| this.init(); | |
| } | |
| init() { | |
| console.log('[EventAnnotationManager] init() called for schema:', this.schemaName); | |
| if (!this.container) { | |
| console.warn(`EventAnnotationManager: Container not found for schema ${this.schemaName}`); | |
| return; | |
| } | |
| // Get DOM references | |
| this.triggerSection = this.container.querySelector('.event-trigger-section'); | |
| this.argumentsSection = this.container.querySelector('.event-arguments-section'); | |
| this.argumentsPanel = document.getElementById(`${this.schemaName}_arguments_panel`); | |
| this.triggerDisplay = document.getElementById(`${this.schemaName}_trigger_display`); | |
| this.eventList = document.getElementById(`${this.schemaName}_event_list`); | |
| this.createButton = document.getElementById(`${this.schemaName}_create_event`); | |
| this.cancelButton = document.getElementById(`${this.schemaName}_cancel_event`); | |
| this.showArcsCheckbox = document.getElementById(`${this.schemaName}_show_arcs`); | |
| this.eventDataInput = document.getElementById(`${this.schemaName}_event_data`); | |
| // Parse event type configurations | |
| this.parseEventTypeConfigs(); | |
| // Set up event listeners | |
| this.setupEventListeners(); | |
| // Create arc rendering container | |
| this.createArcsContainer(); | |
| // Load existing events if any | |
| this.loadExistingEvents(); | |
| console.log(`[EventAnnotationManager] Initialization complete for schema: ${this.schemaName}`); | |
| } | |
| parseEventTypeConfigs() { | |
| this.eventTypeConfigs = {}; | |
| const eventTypes = this.container.querySelectorAll('.event-type'); | |
| eventTypes.forEach(et => { | |
| const typeName = et.dataset.eventType; | |
| const triggerLabels = et.dataset.triggerLabels ? et.dataset.triggerLabels.split(',').filter(Boolean) : []; | |
| let argsList = []; | |
| try { | |
| argsList = JSON.parse(et.dataset.arguments || '[]'); | |
| } catch (e) { | |
| console.error(`[EventAnnotationManager] Failed to parse arguments for ${typeName}:`, e); | |
| } | |
| this.eventTypeConfigs[typeName] = { | |
| color: et.dataset.color || '#dc2626', | |
| triggerLabels: triggerLabels, | |
| arguments: argsList | |
| }; | |
| }); | |
| console.log('[EventAnnotationManager] Event type configs:', this.eventTypeConfigs); | |
| } | |
| setupEventListeners() { | |
| // Event type selection | |
| const eventTypeRadios = this.container.querySelectorAll('.event-type-radio'); | |
| eventTypeRadios.forEach(radio => { | |
| radio.addEventListener('change', (e) => { | |
| this.selectEventType(e.target.value); | |
| }); | |
| }); | |
| // Create button | |
| if (this.createButton) { | |
| this.createButton.addEventListener('click', () => this.createEvent()); | |
| } | |
| // Cancel button | |
| if (this.cancelButton) { | |
| this.cancelButton.addEventListener('click', () => this.cancelEventCreation()); | |
| } | |
| // Escape key cancels event creation | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && this.isEventMode) { | |
| e.preventDefault(); | |
| this.cancelEventCreation(); | |
| } | |
| }); | |
| // Show arcs toggle | |
| if (this.showArcsCheckbox) { | |
| this.showArcsCheckbox.addEventListener('change', (e) => { | |
| this.toggleArcsVisibility(e.target.checked); | |
| }); | |
| } | |
| // Listen for span clicks when in event mode | |
| document.addEventListener('click', (e) => { | |
| if (!this.isEventMode) return; | |
| // Check if clicked on a span overlay | |
| 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) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| e.stopImmediatePropagation(); | |
| this.handleSpanClick(spanOverlay); | |
| } | |
| }, true); | |
| } | |
| selectEventType(eventType) { | |
| console.log(`[EventAnnotationManager] Selecting event type: ${eventType}`); | |
| this.currentEventType = eventType; | |
| this.currentEventConfig = this.eventTypeConfigs[eventType]; | |
| this.state = 'select_trigger'; | |
| this.enterEventMode(); | |
| this.updateUI(); | |
| } | |
| enterEventMode() { | |
| this.isEventMode = true; | |
| // Add visual indicator | |
| this.container.classList.add('event-mode-active'); | |
| document.body.classList.add('event-annotation-mode-active'); | |
| // Get color for current event type | |
| const eventColor = this.currentEventConfig?.color || '#dc2626'; | |
| document.body.style.setProperty('--current-event-color', eventColor); | |
| // Enable span selection | |
| const spanOverlays = document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight'); | |
| spanOverlays.forEach(overlay => { | |
| overlay.classList.add('event-selectable'); | |
| overlay.style.setProperty('--current-event-color', eventColor); | |
| overlay.style.pointerEvents = 'auto'; | |
| overlay.style.cursor = 'pointer'; | |
| }); | |
| // Also enable segments | |
| const segments = document.querySelectorAll('.span-highlight-segment'); | |
| segments.forEach(segment => { | |
| segment.style.pointerEvents = 'auto'; | |
| segment.style.cursor = 'pointer'; | |
| }); | |
| } | |
| exitEventMode() { | |
| this.isEventMode = false; | |
| this.state = 'idle'; | |
| this.container.classList.remove('event-mode-active'); | |
| document.body.classList.remove('event-annotation-mode-active'); | |
| document.body.style.removeProperty('--current-event-color'); | |
| // Remove span selection | |
| const spanOverlays = document.querySelectorAll('.span-overlay-pure, .span-overlay, .span-overlay-ai, .span-highlight'); | |
| spanOverlays.forEach(overlay => { | |
| overlay.classList.remove('event-selectable'); | |
| overlay.classList.remove('event-trigger-selected'); | |
| overlay.classList.remove('event-argument-selected'); | |
| overlay.style.pointerEvents = 'none'; | |
| overlay.style.cursor = ''; | |
| overlay.style.removeProperty('--current-event-color'); | |
| }); | |
| const segments = document.querySelectorAll('.span-highlight-segment'); | |
| segments.forEach(segment => { | |
| segment.style.pointerEvents = 'none'; | |
| segment.style.cursor = ''; | |
| }); | |
| // Deselect radio | |
| const eventTypeRadios = this.container.querySelectorAll('.event-type-radio'); | |
| eventTypeRadios.forEach(radio => radio.checked = false); | |
| this.resetEventState(); | |
| } | |
| resetEventState() { | |
| this.currentEventType = null; | |
| this.currentEventConfig = null; | |
| this.triggerSpan = null; | |
| this.arguments = {}; | |
| this.currentRole = null; | |
| } | |
| cancelEventCreation() { | |
| this.exitEventMode(); | |
| this.updateUI(); | |
| } | |
| handleSpanClick(spanOverlay) { | |
| const spanData = this.extractSpanData(spanOverlay); | |
| console.log(`[EventAnnotationManager] Span clicked:`, spanData); | |
| if (this.state === 'select_trigger') { | |
| this.selectTrigger(spanOverlay, spanData); | |
| } else if (this.state === 'assign_arguments' && this.currentRole) { | |
| this.assignArgument(spanOverlay, spanData); | |
| } | |
| } | |
| extractSpanData(spanOverlay) { | |
| return { | |
| id: spanOverlay.dataset.annotationId, | |
| label: spanOverlay.dataset.label, | |
| text: this.getSpanText(spanOverlay), | |
| start: parseInt(spanOverlay.dataset.start), | |
| end: parseInt(spanOverlay.dataset.end) | |
| }; | |
| } | |
| getSpanText(spanOverlay) { | |
| const start = parseInt(spanOverlay.dataset.start); | |
| const end = parseInt(spanOverlay.dataset.end); | |
| // Get the original plain text from the data-original-text attribute | |
| // This is the text that span offsets are based on | |
| const textContent = document.getElementById('text-content'); | |
| if (textContent && textContent.dataset.originalText) { | |
| return textContent.dataset.originalText.substring(start, end); | |
| } | |
| // Fallback: try to get text from the span overlay's own text content | |
| // This gets the actual visible text within the span | |
| const segments = spanOverlay.querySelectorAll('.span-highlight-segment'); | |
| if (segments.length > 0) { | |
| return Array.from(segments).map(s => s.textContent).join(''); | |
| } | |
| // Last fallback: use the span overlay's text content directly | |
| return spanOverlay.textContent || ''; | |
| } | |
| selectTrigger(spanOverlay, spanData) { | |
| // Check if trigger label constraint is satisfied | |
| const triggerLabels = this.currentEventConfig?.triggerLabels || []; | |
| if (triggerLabels.length > 0 && !triggerLabels.includes(spanData.label)) { | |
| console.warn(`[EventAnnotationManager] Span label ${spanData.label} not allowed as trigger. Allowed: ${triggerLabels.join(', ')}`); | |
| return; | |
| } | |
| // Clear previous trigger selection | |
| const prevTrigger = document.querySelector('.event-trigger-selected'); | |
| if (prevTrigger) { | |
| prevTrigger.classList.remove('event-trigger-selected'); | |
| } | |
| // Mark this span as trigger | |
| spanOverlay.classList.add('event-trigger-selected'); | |
| this.triggerSpan = spanData; | |
| this.state = 'assign_arguments'; | |
| this.updateUI(); | |
| } | |
| assignArgument(spanOverlay, spanData) { | |
| if (!this.currentRole) return; | |
| // Check entity type constraint | |
| const roleConfig = this.currentEventConfig?.arguments.find(a => a.role === this.currentRole); | |
| const entityTypes = roleConfig?.entity_types || []; | |
| if (entityTypes.length > 0 && !entityTypes.includes(spanData.label)) { | |
| console.warn(`[EventAnnotationManager] Span label ${spanData.label} not allowed for role ${this.currentRole}. Allowed: ${entityTypes.join(', ')}`); | |
| return; | |
| } | |
| // Store argument | |
| this.arguments[this.currentRole] = spanData; | |
| spanOverlay.classList.add('event-argument-selected'); | |
| this.currentRole = null; | |
| this.updateUI(); | |
| this.checkCanCreate(); | |
| } | |
| selectRole(role) { | |
| this.currentRole = role; | |
| this.updateUI(); | |
| } | |
| removeArgument(role) { | |
| // Find and unmark the span | |
| const spanData = this.arguments[role]; | |
| if (spanData) { | |
| const overlay = document.querySelector(`[data-annotation-id="${CSS.escape(spanData.id)}"]`); | |
| if (overlay) { | |
| overlay.classList.remove('event-argument-selected'); | |
| } | |
| } | |
| delete this.arguments[role]; | |
| this.updateUI(); | |
| this.checkCanCreate(); | |
| } | |
| checkCanCreate() { | |
| // Check if all required arguments are filled | |
| const args = this.currentEventConfig?.arguments || []; | |
| const requiredRoles = args.filter(a => a.required).map(a => a.role); | |
| const filledRoles = Object.keys(this.arguments); | |
| const canCreate = this.triggerSpan && | |
| requiredRoles.every(role => filledRoles.includes(role)); | |
| if (this.createButton) { | |
| this.createButton.disabled = !canCreate; | |
| } | |
| return canCreate; | |
| } | |
| createEvent() { | |
| if (!this.checkCanCreate()) return; | |
| console.log(`[EventAnnotationManager] createEvent() called`); | |
| console.log(`[EventAnnotationManager] Events BEFORE create: ${this.events.length}`, this.events.map(e => e.id)); | |
| // IMPORTANT: Cache span positions BEFORE exiting event mode | |
| // While in event mode, spans are visible and correctly positioned | |
| // After exitEventMode, there may be layout delays | |
| this.cacheSpanPositions(); | |
| // Build event data | |
| const eventData = { | |
| id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, | |
| schema: this.schemaName, | |
| event_type: this.currentEventType, | |
| trigger_span_id: this.triggerSpan.id, | |
| arguments: Object.entries(this.arguments).map(([role, span]) => ({ | |
| role: role, | |
| span_id: span.id | |
| })), | |
| properties: { | |
| color: this.currentEventConfig?.color || '#dc2626', | |
| trigger_text: this.triggerSpan.text, | |
| trigger_label: this.triggerSpan.label | |
| } | |
| }; | |
| // Check if this event already exists (prevent duplicates) | |
| const existingEventIndex = this.events.findIndex(e => | |
| e.trigger_span_id === eventData.trigger_span_id && | |
| e.event_type === eventData.event_type | |
| ); | |
| if (existingEventIndex >= 0) { | |
| console.warn(`[EventAnnotationManager] Event with same trigger and type already exists, updating instead of adding`); | |
| this.events[existingEventIndex] = eventData; | |
| } else { | |
| // Add to events list | |
| this.events.push(eventData); | |
| } | |
| console.log(`[EventAnnotationManager] Created event: ${eventData.id}, total events: ${this.events.length}`); | |
| console.log(`[EventAnnotationManager] Events AFTER create:`, this.events.map(e => e.id)); | |
| // Update hidden input | |
| this.updateEventDataInput(); | |
| // Sync to backend | |
| this.syncToBackend(); | |
| // Exit event mode | |
| this.exitEventMode(); | |
| this.updateUI(); | |
| // Use delayed rendering since cached positions didn't work | |
| // The spans need time to settle after exitEventMode | |
| this.renderArcsWithDelay(); | |
| } | |
| /** | |
| * Render arcs with a delay to allow DOM to settle. | |
| * Uses requestAnimationFrame to ensure we render after the browser has painted. | |
| */ | |
| renderArcsWithDelay(attempt = 0) { | |
| console.log(`[EventAnnotationManager] renderArcsWithDelay attempt ${attempt}`); | |
| console.log(`[EventAnnotationManager] Current events count: ${this.events.length}`); | |
| // Use requestAnimationFrame to wait for next paint | |
| requestAnimationFrame(() => { | |
| // Then use another RAF to ensure layout is complete | |
| requestAnimationFrame(() => { | |
| // Add a small timeout to allow any CSS transitions to complete | |
| setTimeout(() => { | |
| this.tryRenderArcs(attempt); | |
| }, 50); | |
| }); | |
| }); | |
| } | |
| tryRenderArcs(attempt) { | |
| const maxAttempts = 15; | |
| if (this.events.length === 0) { | |
| console.log(`[EventAnnotationManager] No events to render`); | |
| return; | |
| } | |
| // Force a reflow by scrolling the instance text into view | |
| const instanceText = document.getElementById('instance-text'); | |
| if (instanceText && attempt === 0) { | |
| instanceText.scrollIntoView({ behavior: 'instant', block: 'center' }); | |
| // Force synchronous reflow | |
| void instanceText.offsetHeight; | |
| } | |
| // Check if spans have valid positions | |
| const lastEvent = this.events[this.events.length - 1]; | |
| const triggerOverlay = document.querySelector(`[data-annotation-id="${CSS.escape(lastEvent.trigger_span_id)}"]`); | |
| if (!triggerOverlay) { | |
| console.warn(`[EventAnnotationManager] Trigger overlay not found: ${lastEvent.trigger_span_id}`); | |
| if (attempt < maxAttempts) { | |
| setTimeout(() => this.tryRenderArcs(attempt + 1), 100); | |
| } | |
| return; | |
| } | |
| // Force reflow on the overlay | |
| void triggerOverlay.offsetHeight; | |
| // Get position using segments if available | |
| let hasValidPosition = false; | |
| let measureRect = null; | |
| const segments = triggerOverlay.querySelectorAll('.span-highlight-segment'); | |
| if (segments.length > 0) { | |
| const segRect = segments[0].getBoundingClientRect(); | |
| measureRect = segRect; | |
| console.log(`[EventAnnotationManager] Attempt ${attempt}: Segment rect: w=${segRect.width}, h=${segRect.height}, top=${segRect.top}`); | |
| // Valid if has non-zero size (top can be negative if scrolled) | |
| hasValidPosition = segRect.width > 0 && segRect.height > 0; | |
| } else { | |
| const rect = triggerOverlay.getBoundingClientRect(); | |
| measureRect = rect; | |
| console.log(`[EventAnnotationManager] Attempt ${attempt}: Overlay rect: w=${rect.width}, h=${rect.height}, top=${rect.top}`); | |
| hasValidPosition = rect.width > 0 && rect.height > 0; | |
| } | |
| // Also check if textWrapper has valid dimensions | |
| if (hasValidPosition && this.textWrapper) { | |
| const wrapperRect = this.textWrapper.getBoundingClientRect(); | |
| console.log(`[EventAnnotationManager] TextWrapper rect: w=${wrapperRect.width}, h=${wrapperRect.height}`); | |
| if (wrapperRect.width === 0 || wrapperRect.height === 0) { | |
| hasValidPosition = false; | |
| } | |
| } | |
| if (hasValidPosition) { | |
| console.log(`[EventAnnotationManager] Valid positions found, rendering arcs`); | |
| this.renderArcs(false); | |
| } else if (attempt < maxAttempts) { | |
| const delay = attempt < 3 ? 100 : (attempt < 6 ? 200 : 300); | |
| console.log(`[EventAnnotationManager] Position not valid yet, retry in ${delay}ms...`); | |
| setTimeout(() => this.tryRenderArcs(attempt + 1), delay); | |
| } else { | |
| console.warn(`[EventAnnotationManager] Max attempts reached, rendering anyway`); | |
| this.renderArcs(false); | |
| } | |
| } | |
| /** | |
| * Cache span positions while they're correctly laid out. | |
| * This is called before exitEventMode to capture positions when spans are visible. | |
| */ | |
| cacheSpanPositions() { | |
| this._cachedPositions = {}; | |
| console.log(`[EventAnnotationManager] cacheSpanPositions called, textWrapper exists: ${!!this.textWrapper}`); | |
| if (!this.textWrapper) { | |
| console.warn('[EventAnnotationManager] textWrapper not available for caching'); | |
| // Try to find it | |
| const instanceText = document.getElementById('instance-text'); | |
| if (instanceText) { | |
| this.textWrapper = instanceText.querySelector('.event-annotation-text-wrapper'); | |
| console.log(`[EventAnnotationManager] Found textWrapper: ${!!this.textWrapper}`); | |
| } | |
| } | |
| if (!this.textWrapper) { | |
| console.error('[EventAnnotationManager] Cannot cache positions - no textWrapper'); | |
| return; | |
| } | |
| // Cache all span overlay positions | |
| const overlays = document.querySelectorAll('[data-annotation-id]'); | |
| console.log(`[EventAnnotationManager] Found ${overlays.length} overlays to cache`); | |
| overlays.forEach(overlay => { | |
| const id = overlay.dataset.annotationId; | |
| if (id) { | |
| const rect = overlay.getBoundingClientRect(); | |
| const wrapperRect = this.textWrapper.getBoundingClientRect(); | |
| console.log(`[EventAnnotationManager] Overlay ${id}: rect =`, rect, 'wrapperRect =', wrapperRect); | |
| if (rect.width > 0 && wrapperRect.width > 0) { | |
| this._cachedPositions[id] = { | |
| left: rect.left - wrapperRect.left, | |
| right: rect.right - wrapperRect.left, | |
| centerX: (rect.left + rect.right) / 2 - wrapperRect.left, | |
| width: rect.width | |
| }; | |
| console.log(`[EventAnnotationManager] Cached position for ${id}:`, this._cachedPositions[id]); | |
| } else { | |
| console.warn(`[EventAnnotationManager] Zero-width rect for ${id}, skipping cache`); | |
| } | |
| } | |
| }); | |
| console.log(`[EventAnnotationManager] Cached ${Object.keys(this._cachedPositions).length} span positions`); | |
| } | |
| /** | |
| * Attempt to render arcs, retrying if positions aren't ready yet. | |
| * This handles the timing issue where getBoundingClientRect() returns 0 | |
| * before the DOM has fully laid out. | |
| */ | |
| renderArcsWhenReady(retries = 0, maxRetries = 15) { | |
| // Use requestAnimationFrame to wait for next paint, then check positions | |
| requestAnimationFrame(() => { | |
| // Force a reflow by reading offsetHeight | |
| if (this.textWrapper) { | |
| void this.textWrapper.offsetHeight; | |
| } | |
| // Wait one more frame for the paint to complete | |
| requestAnimationFrame(() => { | |
| // Check if we can get valid positions | |
| if (this.events.length > 0) { | |
| const firstEvent = this.events[0]; | |
| const triggerOverlay = document.querySelector(`[data-annotation-id="${CSS.escape(firstEvent.trigger_span_id)}"]`); | |
| if (triggerOverlay && this.textWrapper) { | |
| const rect = triggerOverlay.getBoundingClientRect(); | |
| const wrapperRect = this.textWrapper.getBoundingClientRect(); | |
| console.log(`[EventAnnotationManager] renderArcsWhenReady check: triggerRect =`, rect); | |
| console.log(`[EventAnnotationManager] renderArcsWhenReady check: wrapperRect =`, wrapperRect); | |
| // If position is still invalid (left=0 usually means not laid out), retry | |
| if (rect.width === 0 || wrapperRect.width === 0 || (rect.left === 0 && retries < 5)) { | |
| if (retries < maxRetries) { | |
| console.log(`[EventAnnotationManager] Positions not ready, retry ${retries + 1}/${maxRetries}`); | |
| // Use setTimeout for subsequent retries to avoid tight loop | |
| setTimeout(() => this.renderArcsWhenReady(retries + 1, maxRetries), 50); | |
| return; | |
| } else { | |
| console.warn('[EventAnnotationManager] Max retries reached, rendering anyway'); | |
| } | |
| } | |
| } | |
| } | |
| console.log('[EventAnnotationManager] Rendering arcs now'); | |
| this.renderArcs(); | |
| }); | |
| }); | |
| } | |
| updateEventDataInput() { | |
| if (this.eventDataInput) { | |
| this.eventDataInput.value = JSON.stringify(this.events); | |
| } | |
| } | |
| async syncToBackend() { | |
| const instanceId = window.currentInstanceId || this.getInstanceId(); | |
| if (!instanceId) { | |
| console.error('[EventAnnotationManager] No instance ID found'); | |
| return; | |
| } | |
| console.log(`[EventAnnotationManager] syncToBackend() called`); | |
| console.log(`[EventAnnotationManager] Syncing ${this.events.length} events to backend for instance ${instanceId}`); | |
| console.log('[EventAnnotationManager] Event IDs being sent:', this.events.map(e => e.id)); | |
| console.log('[EventAnnotationManager] Full event data:', JSON.stringify(this.events, null, 2)); | |
| try { | |
| const response = await fetch('/updateinstance', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| instance_id: instanceId, | |
| event_annotations: this.events | |
| }) | |
| }); | |
| if (!response.ok) { | |
| console.error('[EventAnnotationManager] Failed to sync events:', response.status); | |
| const text = await response.text(); | |
| console.error('[EventAnnotationManager] Response:', text); | |
| } else { | |
| const data = await response.json(); | |
| console.log('[EventAnnotationManager] Sync successful, response:', data); | |
| } | |
| } catch (error) { | |
| console.error('[EventAnnotationManager] Error syncing events:', error); | |
| } | |
| } | |
| getInstanceId() { | |
| // Try various ways to get instance ID | |
| // The template uses 'instance_id' as the hidden input ID | |
| const idElem = document.getElementById('instance_id'); | |
| if (idElem) return idElem.value || idElem.textContent; | |
| // Fallback to other common patterns | |
| const altIdElem = document.getElementById('current_instance_id'); | |
| if (altIdElem) return altIdElem.value || altIdElem.textContent; | |
| const instanceText = document.getElementById('instance-text'); | |
| if (instanceText && instanceText.dataset.instanceId) return instanceText.dataset.instanceId; | |
| return null; | |
| } | |
| async loadExistingEvents() { | |
| const instanceId = window.currentInstanceId || this.getInstanceId(); | |
| if (!instanceId) { | |
| console.warn('[EventAnnotationManager] No instance ID for loading events'); | |
| return; | |
| } | |
| // Prevent loading if we've already loaded for this instance | |
| if (this._loadedForInstance === instanceId && this.events.length > 0) { | |
| console.log(`[EventAnnotationManager] Already loaded events for instance ${instanceId}, skipping`); | |
| return; | |
| } | |
| console.log(`[EventAnnotationManager] loadExistingEvents() called for instance: ${instanceId}`); | |
| console.log(`[EventAnnotationManager] Events BEFORE load: ${this.events.length}`, this.events.map(e => e.id)); | |
| try { | |
| const response = await fetch(`/api/events/${instanceId}`); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| console.log(`[EventAnnotationManager] Server returned:`, JSON.stringify(data)); | |
| if (data.events && data.events.length > 0) { | |
| console.log(`[EventAnnotationManager] Server event IDs:`, data.events.map(e => e.id)); | |
| // Deduplicate events by ID | |
| const seenIds = new Set(); | |
| const uniqueEvents = []; | |
| for (const event of data.events) { | |
| if (!seenIds.has(event.id)) { | |
| seenIds.add(event.id); | |
| uniqueEvents.push(event); | |
| } else { | |
| console.warn(`[EventAnnotationManager] Duplicate event ID found: ${event.id}`); | |
| } | |
| } | |
| this.events = uniqueEvents; | |
| this._loadedForInstance = instanceId; | |
| this.updateEventDataInput(); | |
| this.updateUI(); | |
| this.renderArcs(); | |
| console.log(`[EventAnnotationManager] Loaded ${this.events.length} unique events`); | |
| } else { | |
| console.log(`[EventAnnotationManager] No events returned from server`); | |
| this.events = []; | |
| this._loadedForInstance = instanceId; | |
| } | |
| } else { | |
| console.log(`[EventAnnotationManager] Server returned status: ${response.status}`); | |
| } | |
| } catch (error) { | |
| console.error('[EventAnnotationManager] Error loading events:', error); | |
| } | |
| } | |
| async deleteEvent(eventId) { | |
| const instanceId = window.currentInstanceId || this.getInstanceId(); | |
| if (!instanceId) return; | |
| try { | |
| const response = await fetch(`/api/events/${instanceId}/${eventId}`, { | |
| method: 'DELETE' | |
| }); | |
| if (response.ok) { | |
| this.events = this.events.filter(e => e.id !== eventId); | |
| this.updateEventDataInput(); | |
| this.updateUI(); | |
| this.renderArcs(); | |
| } | |
| } catch (error) { | |
| console.error('[EventAnnotationManager] Error deleting event:', error); | |
| } | |
| } | |
| updateUI() { | |
| // Update trigger section visibility | |
| if (this.triggerSection) { | |
| this.triggerSection.style.display = | |
| (this.state === 'select_trigger' || this.state === 'assign_arguments') ? 'block' : 'none'; | |
| } | |
| // Update arguments section visibility | |
| if (this.argumentsSection) { | |
| this.argumentsSection.style.display = | |
| this.state === 'assign_arguments' ? 'block' : 'none'; | |
| } | |
| // Update trigger display | |
| this.updateTriggerDisplay(); | |
| // Update arguments panel | |
| this.updateArgumentsPanel(); | |
| // Update event list | |
| this.updateEventList(); | |
| // Check if create button should be enabled | |
| this.checkCanCreate(); | |
| } | |
| updateTriggerDisplay() { | |
| if (!this.triggerDisplay) return; | |
| if (this.triggerSpan) { | |
| const color = this.currentEventConfig?.color || '#dc2626'; | |
| this.triggerDisplay.innerHTML = ` | |
| <div class="event-trigger-chip" style="--event-color: ${color}"> | |
| <span class="trigger-icon">T</span> | |
| <span class="trigger-text">${this.escapeHtml(this.triggerSpan.text)}</span> | |
| <span class="trigger-label">${this.escapeHtml(this.triggerSpan.label)}</span> | |
| </div> | |
| `; | |
| } else if (this.state === 'select_trigger') { | |
| this.triggerDisplay.innerHTML = '<p class="no-trigger-message">Click on a span to set it as the event trigger</p>'; | |
| } else { | |
| this.triggerDisplay.innerHTML = ''; | |
| } | |
| } | |
| updateArgumentsPanel() { | |
| if (!this.argumentsPanel) return; | |
| const args = this.currentEventConfig?.arguments || []; | |
| if (args.length === 0) { | |
| this.argumentsPanel.innerHTML = '<p class="no-arguments-message">No arguments defined for this event type</p>'; | |
| return; | |
| } | |
| const color = this.currentEventConfig?.color || '#dc2626'; | |
| let html = ''; | |
| for (const arg of args) { | |
| const role = arg.role; | |
| const required = arg.required; | |
| const entityTypes = arg.entity_types || []; | |
| const filledSpan = this.arguments[role]; | |
| const isActive = this.currentRole === role; | |
| html += `<div class="event-argument-row ${filledSpan ? 'filled' : ''} ${isActive ? 'active' : ''}" | |
| data-role="${this.escapeHtml(role)}"> | |
| <div class="event-role-button ${isActive ? 'active' : ''}" | |
| style="--event-color: ${color}" | |
| onclick="window.eventAnnotationManagers['${this.schemaName}'].selectRole('${this.escapeHtml(role)}')"> | |
| <span class="role-name">${this.escapeHtml(role)}</span> | |
| ${required ? '<span class="required-indicator">*</span>' : ''} | |
| </div>`; | |
| if (filledSpan) { | |
| html += ` | |
| <div class="event-argument-chip" style="--event-color: ${color}"> | |
| <span class="argument-text">${this.escapeHtml(filledSpan.text)}</span> | |
| <span class="argument-label">${this.escapeHtml(filledSpan.label)}</span> | |
| <button class="argument-remove-btn" onclick="window.eventAnnotationManagers['${this.schemaName}'].removeArgument('${this.escapeHtml(role)}')">x</button> | |
| </div>`; | |
| } else if (entityTypes.length > 0) { | |
| html += `<span class="entity-type-hint">(${entityTypes.join(', ')})</span>`; | |
| } | |
| html += '</div>'; | |
| } | |
| this.argumentsPanel.innerHTML = html; | |
| } | |
| updateEventList() { | |
| if (!this.eventList) return; | |
| if (this.events.length === 0) { | |
| this.eventList.innerHTML = '<p class="no-events-message">No events created yet</p>'; | |
| return; | |
| } | |
| let html = ''; | |
| for (const event of this.events) { | |
| const config = this.eventTypeConfigs[event.event_type] || {}; | |
| const rawColor = event.properties?.color || config.color || '#dc2626'; | |
| // Sanitize color: only allow hex colors, named colors, and rgb/hsl functions | |
| const color = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|rgba?\([^)]+\)|hsla?\([^)]+\))$/.test(rawColor) ? rawColor : '#dc2626'; | |
| const safeEventId = this.escapeHtml(event.id); | |
| html += ` | |
| <div class="event-item" data-event-id="${safeEventId}" style="--event-color: ${color}"> | |
| <div class="event-item-header"> | |
| <span class="event-type-badge" style="background-color: ${color}">${this.escapeHtml(event.event_type)}</span> | |
| <button class="event-delete-btn" data-event-id="${safeEventId}" title="Delete event"> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <line x1="18" y1="6" x2="6" y2="18"></line> | |
| <line x1="6" y1="6" x2="18" y2="18"></line> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="event-item-details"> | |
| <div class="event-trigger-info"> | |
| <span class="trigger-role">Trigger:</span> | |
| <span class="trigger-value">${this.escapeHtml(event.properties?.trigger_text || '?')}</span> | |
| </div>`; | |
| for (const arg of event.arguments || []) { | |
| const spanText = this.getSpanTextById(arg.span_id); | |
| html += ` | |
| <div class="event-argument-info"> | |
| <span class="argument-role">${this.escapeHtml(arg.role)}:</span> | |
| <span class="argument-value">${this.escapeHtml(spanText || '?')}</span> | |
| </div>`; | |
| } | |
| html += `</div></div>`; | |
| } | |
| this.eventList.innerHTML = html; | |
| // Attach delete handlers via event delegation (avoids inline onclick XSS risk) | |
| this.eventList.querySelectorAll('.event-delete-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const eventId = btn.getAttribute('data-event-id'); | |
| if (eventId) this.deleteEvent(eventId); | |
| }); | |
| }); | |
| } | |
| getSpanTextById(spanId) { | |
| const overlay = document.querySelector(`[data-annotation-id="${CSS.escape(spanId)}"]`); | |
| if (overlay) { | |
| return this.getSpanText(overlay); | |
| } | |
| return null; | |
| } | |
| createArcsContainer() { | |
| console.log(`[EventAnnotationManager] createArcsContainer() called`); | |
| const showArcs = this.container.dataset.showArcs !== 'false'; | |
| console.log(`[EventAnnotationManager] showArcs: ${showArcs}, dataset.showArcs: ${this.container.dataset.showArcs}`); | |
| if (!showArcs) { | |
| console.log(`[EventAnnotationManager] showArcs is false, skipping container creation`); | |
| return; | |
| } | |
| const instanceText = document.getElementById('instance-text'); | |
| console.log(`[EventAnnotationManager] instanceText found: ${!!instanceText}`); | |
| if (!instanceText) { | |
| console.warn(`[EventAnnotationManager] instance-text element not found!`); | |
| return; | |
| } | |
| // Check if wrapper already exists | |
| const existingWrapper = instanceText.querySelector('.event-annotation-text-wrapper'); | |
| if (existingWrapper) { | |
| this.textWrapper = existingWrapper; | |
| this.arcSpacer = instanceText.querySelector('.event-annotation-arc-spacer'); | |
| this.arcsContainer = instanceText.querySelector('.event-annotation-arcs-container'); | |
| return; | |
| } | |
| // Store config | |
| this.arcPosition = this.container.dataset.arcPosition || 'above'; | |
| this.instanceText = instanceText; | |
| // Create wrapper structure | |
| this.textWrapper = document.createElement('div'); | |
| this.textWrapper.className = 'event-annotation-text-wrapper'; | |
| this.textWrapper.style.cssText = 'position: relative;'; | |
| while (instanceText.firstChild) { | |
| this.textWrapper.appendChild(instanceText.firstChild); | |
| } | |
| this.arcSpacer = document.createElement('div'); | |
| this.arcSpacer.className = 'event-annotation-arc-spacer'; | |
| this.arcSpacer.style.cssText = ` | |
| position: relative; | |
| width: 100%; | |
| height: 80px; | |
| min-height: 80px; | |
| `; | |
| this.arcsContainer = document.createElement('div'); | |
| this.arcsContainer.id = `${this.schemaName}_arcs`; | |
| this.arcsContainer.className = 'event-annotation-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); | |
| } | |
| renderArcs(useCachedPositions = false) { | |
| console.log(`[EventAnnotationManager] ========== renderArcs START ==========`); | |
| console.log(`[EventAnnotationManager] useCachedPositions=${useCachedPositions}`); | |
| console.log(`[EventAnnotationManager] arcsContainer exists: ${!!this.arcsContainer}`); | |
| console.log(`[EventAnnotationManager] textWrapper exists: ${!!this.textWrapper}`); | |
| console.log(`[EventAnnotationManager] events count: ${this.events.length}`); | |
| if (!this.arcsContainer) { | |
| console.error(`[EventAnnotationManager] No arcsContainer - cannot render`); | |
| return; | |
| } | |
| // Clear existing arcs | |
| this.arcsContainer.innerHTML = ''; | |
| console.log(`[EventAnnotationManager] Cleared arcsContainer`); | |
| if (this.events.length === 0) { | |
| console.log(`[EventAnnotationManager] No events to render`); | |
| this.updateArcSpacerHeight(0); | |
| return; | |
| } | |
| // Build SVG for event arcs | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| svg.setAttribute('class', 'event-arcs-svg'); | |
| svg.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: visible;'; | |
| // Add defs for markers | |
| const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); | |
| // Create arrow marker for each event type color | |
| const colors = new Set(this.events.map(e => e.properties?.color || '#dc2626')); | |
| for (const color of colors) { | |
| const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); | |
| marker.setAttribute('id', `event-arrow-${color.replace('#', '')}`); | |
| marker.setAttribute('markerWidth', '8'); | |
| marker.setAttribute('markerHeight', '6'); | |
| marker.setAttribute('refX', '7'); | |
| marker.setAttribute('refY', '3'); | |
| marker.setAttribute('orient', 'auto'); | |
| const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); | |
| polygon.setAttribute('points', '0 0, 8 3, 0 6'); | |
| polygon.setAttribute('fill', color); | |
| marker.appendChild(polygon); | |
| defs.appendChild(marker); | |
| } | |
| svg.appendChild(defs); | |
| let maxHeight = 0; | |
| const baseY = 60; // Start position for arcs | |
| for (let eventIndex = 0; eventIndex < this.events.length; eventIndex++) { | |
| const event = this.events[eventIndex]; | |
| const color = event.properties?.color || '#dc2626'; | |
| const levelOffset = eventIndex * 25; // Stack events vertically | |
| // Get trigger position | |
| console.log(`[EventAnnotationManager] Looking for trigger: ${event.trigger_span_id}`); | |
| const triggerOverlay = document.querySelector(`[data-annotation-id="${CSS.escape(event.trigger_span_id)}"]`); | |
| if (!triggerOverlay) { | |
| console.warn(`[EventAnnotationManager] Trigger overlay not found: ${event.trigger_span_id}`); | |
| continue; | |
| } | |
| console.log(`[EventAnnotationManager] Found trigger overlay, getting position (cached=${useCachedPositions})`); | |
| const triggerRect = this.getSpanPosition(triggerOverlay, useCachedPositions); | |
| if (!triggerRect) { | |
| console.warn(`[EventAnnotationManager] Could not get trigger position`); | |
| continue; | |
| } | |
| console.log(`[EventAnnotationManager] Trigger position: centerX=${triggerRect.centerX}`); | |
| const triggerX = triggerRect.centerX; | |
| const triggerY = baseY - levelOffset; | |
| // Draw hub at trigger | |
| const hub = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); | |
| hub.setAttribute('cx', triggerX); | |
| hub.setAttribute('cy', triggerY); | |
| hub.setAttribute('r', '6'); | |
| hub.setAttribute('fill', color); | |
| hub.setAttribute('class', 'event-hub'); | |
| hub.setAttribute('data-event-id', event.id); | |
| svg.appendChild(hub); | |
| // Draw label for event type | |
| const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | |
| label.setAttribute('x', triggerX); | |
| label.setAttribute('y', triggerY - 12); | |
| label.setAttribute('text-anchor', 'middle'); | |
| label.setAttribute('font-size', '10'); | |
| label.setAttribute('fill', color); | |
| label.setAttribute('class', 'event-type-label'); | |
| label.textContent = event.event_type; | |
| svg.appendChild(label); | |
| maxHeight = Math.max(maxHeight, triggerY + 20); | |
| // Draw spokes to arguments | |
| for (const arg of event.arguments || []) { | |
| const argOverlay = document.querySelector(`[data-annotation-id="${CSS.escape(arg.span_id)}"]`); | |
| if (!argOverlay) continue; | |
| const argRect = this.getSpanPosition(argOverlay, useCachedPositions); | |
| if (!argRect) continue; | |
| const argX = argRect.centerX; | |
| const argY = triggerY + 30; // Connect to bottom of hub area | |
| // Draw arc from hub to argument | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| const midY = (triggerY + argY) / 2 - 10; | |
| const d = `M ${triggerX} ${triggerY + 6} Q ${(triggerX + argX) / 2} ${midY} ${argX} ${argY}`; | |
| path.setAttribute('d', d); | |
| path.setAttribute('stroke', color); | |
| path.setAttribute('stroke-width', '1.5'); | |
| path.setAttribute('fill', 'none'); | |
| path.setAttribute('marker-end', `url(#event-arrow-${color.replace('#', '')})`); | |
| path.setAttribute('class', 'event-arc'); | |
| path.setAttribute('data-event-id', event.id); | |
| path.setAttribute('data-role', arg.role); | |
| svg.appendChild(path); | |
| // Draw role label | |
| const roleLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | |
| roleLabel.setAttribute('x', argX); | |
| roleLabel.setAttribute('y', argY + 12); | |
| roleLabel.setAttribute('text-anchor', 'middle'); | |
| roleLabel.setAttribute('font-size', '9'); | |
| roleLabel.setAttribute('fill', color); | |
| roleLabel.setAttribute('class', 'event-role-label'); | |
| roleLabel.textContent = arg.role; | |
| svg.appendChild(roleLabel); | |
| } | |
| } | |
| this.arcsContainer.appendChild(svg); | |
| this.updateArcSpacerHeight(maxHeight + 20); | |
| // Log what was rendered | |
| const hubsRendered = svg.querySelectorAll('.event-hub').length; | |
| const arcsRendered = svg.querySelectorAll('.event-arc').length; | |
| console.log(`[EventAnnotationManager] ========== renderArcs COMPLETE ==========`); | |
| console.log(`[EventAnnotationManager] Hubs rendered: ${hubsRendered}`); | |
| console.log(`[EventAnnotationManager] Arcs rendered: ${arcsRendered}`); | |
| console.log(`[EventAnnotationManager] maxHeight: ${maxHeight}`); | |
| } | |
| getSpanPosition(overlay, useCached = false) { | |
| if (!overlay) { | |
| console.warn('[EventAnnotationManager] getSpanPosition: missing overlay'); | |
| return null; | |
| } | |
| const spanId = overlay.dataset?.annotationId; | |
| // Try cached position first if requested | |
| if (useCached && spanId && this._cachedPositions && this._cachedPositions[spanId]) { | |
| console.log(`[EventAnnotationManager] Using cached position for ${spanId}`); | |
| return this._cachedPositions[spanId]; | |
| } | |
| if (!this.textWrapper) { | |
| console.warn('[EventAnnotationManager] getSpanPosition: missing textWrapper'); | |
| return null; | |
| } | |
| // The overlay container might have zero dimensions | |
| // Get dimensions from the visible segment children instead | |
| let rect = overlay.getBoundingClientRect(); | |
| if (rect.width === 0 || rect.height === 0) { | |
| // Try to get bounds from child segments | |
| const segments = overlay.querySelectorAll('.span-highlight-segment, .span-segment'); | |
| if (segments.length > 0) { | |
| // Calculate bounding box of all segments | |
| let minLeft = Infinity, maxRight = -Infinity; | |
| let minTop = Infinity, maxBottom = -Infinity; | |
| segments.forEach(seg => { | |
| const segRect = seg.getBoundingClientRect(); | |
| if (segRect.width > 0) { | |
| minLeft = Math.min(minLeft, segRect.left); | |
| maxRight = Math.max(maxRight, segRect.right); | |
| minTop = Math.min(minTop, segRect.top); | |
| maxBottom = Math.max(maxBottom, segRect.bottom); | |
| } | |
| }); | |
| if (minLeft !== Infinity) { | |
| rect = { | |
| left: minLeft, | |
| right: maxRight, | |
| top: minTop, | |
| bottom: maxBottom, | |
| width: maxRight - minLeft, | |
| height: maxBottom - minTop | |
| }; | |
| console.log(`[EventAnnotationManager] Using segment bounds for ${spanId}:`, rect); | |
| } | |
| } | |
| } | |
| const wrapperRect = this.textWrapper.getBoundingClientRect(); | |
| // Check for invalid positions | |
| if (rect.width === 0 || wrapperRect.width === 0) { | |
| console.warn(`[EventAnnotationManager] getSpanPosition: zero-width rect for ${spanId}`); | |
| return null; | |
| } | |
| const pos = { | |
| left: rect.left - wrapperRect.left, | |
| right: rect.right - wrapperRect.left, | |
| centerX: (rect.left + rect.right) / 2 - wrapperRect.left, | |
| width: rect.width | |
| }; | |
| return pos; | |
| } | |
| updateArcSpacerHeight(height) { | |
| if (!this.arcSpacer) return; | |
| const totalHeight = Math.max(40, height + 20); | |
| this.arcSpacer.style.height = `${totalHeight}px`; | |
| this.arcSpacer.style.minHeight = `${totalHeight}px`; | |
| } | |
| toggleArcsVisibility(visible) { | |
| if (this.arcsContainer) { | |
| this.arcsContainer.style.display = visible ? 'block' : 'none'; | |
| } | |
| if (this.arcSpacer) { | |
| this.arcSpacer.style.display = visible ? 'block' : 'none'; | |
| } | |
| } | |
| escapeHtml(text) { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| } | |
| // Global registry for event annotation managers | |
| window.eventAnnotationManagers = window.eventAnnotationManagers || {}; | |
| // Initialize event annotation managers when DOM is ready | |
| document.addEventListener('DOMContentLoaded', function() { | |
| console.log('[EventAnnotationManager] DOMContentLoaded fired'); | |
| const containers = document.querySelectorAll('.event-annotation-container'); | |
| console.log(`[EventAnnotationManager] Found ${containers.length} containers`); | |
| containers.forEach(container => { | |
| const schemaName = container.id; | |
| const spanSchema = container.dataset.spanSchema; | |
| if (schemaName) { | |
| // Check if already initialized | |
| if (window.eventAnnotationManagers[schemaName]) { | |
| console.warn(`[EventAnnotationManager] Manager for ${schemaName} already exists, skipping initialization`); | |
| return; | |
| } | |
| console.log(`[EventAnnotationManager] Initializing manager for schema: ${schemaName}`); | |
| window.eventAnnotationManagers[schemaName] = new EventAnnotationManager(schemaName, spanSchema); | |
| } | |
| }); | |
| }); | |