/** * 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 = `