// Event rendering strategy interface and implementations import { CalendarEvent } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; import { SwpEventElement } from '../elements/SwpEventElement'; import { PositionUtils } from '../utils/PositionUtils'; import { ColumnBounds } from '../utils/ColumnDetectionUtils'; import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes'; import { DateService } from '../utils/DateService'; import { EventStackManager, EventGroup, StackLink } from '../managers/EventStackManager'; /** * Interface for event rendering strategies */ export interface EventRendererStrategy { renderEvents(events: CalendarEvent[], container: HTMLElement): void; clearEvents(container?: HTMLElement): void; handleDragStart?(payload: DragStartEventPayload): void; handleDragMove?(payload: DragMoveEventPayload): void; handleDragAutoScroll?(eventId: string, snappedY: number): void; handleDragEnd?(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void; handleEventClick?(eventId: string, originalElement: HTMLElement): void; handleColumnChange?(payload: DragColumnChangeEventPayload): void; handleNavigationCompleted?(): void; } /** * Date-based event renderer */ export class DateEventRenderer implements EventRendererStrategy { private dateService: DateService; private stackManager: EventStackManager; private draggedClone: HTMLElement | null = null; private originalEvent: HTMLElement | null = null; constructor() { const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); this.stackManager = new EventStackManager(); } private applyDragStyling(element: HTMLElement): void { element.classList.add('dragging'); element.style.removeProperty("margin-left"); } /** * Handle drag start event */ public handleDragStart(payload: DragStartEventPayload): void { this.originalEvent = payload.draggedElement;; // Use the clone from the payload instead of creating a new one this.draggedClone = payload.draggedClone; if (this.draggedClone) { // Apply drag styling this.applyDragStyling(this.draggedClone); // Add to current column's events layer (not directly to column) const eventsLayer = payload.columnBounds?.element.querySelector('swp-events-layer'); if (eventsLayer) { eventsLayer.appendChild(this.draggedClone); } } // Make original semi-transparent this.originalEvent.style.opacity = '0.3'; this.originalEvent.style.userSelect = 'none'; } /** * Handle drag move event */ public handleDragMove(payload: DragMoveEventPayload): void { if (!this.draggedClone || !payload.columnBounds) return; // Delegate to SwpEventElement to update position and timestamps const swpEvent = this.draggedClone as SwpEventElement; const columnDate = new Date(payload.columnBounds.date); swpEvent.updatePosition(columnDate, payload.snappedY); } /** * Handle drag auto-scroll event */ public handleDragAutoScroll(eventId: string, snappedY: number): void { if (!this.draggedClone) return; // Update position directly using the calculated snapped position this.draggedClone.style.top = snappedY + 'px'; // Update timestamp display //this.updateCloneTimestamp(this.draggedClone, snappedY); //TODO: Commented as, we need to move all this scroll logic til scroll manager away from eventrenderer } /** * Handle column change during drag */ public handleColumnChange(dragColumnChangeEvent: DragColumnChangeEventPayload): void { if (!this.draggedClone) return; const eventsLayer = dragColumnChangeEvent.newColumn.element.querySelector('swp-events-layer'); if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) { eventsLayer.appendChild(this.draggedClone); // Recalculate timestamps with new column date const currentTop = parseFloat(this.draggedClone.style.top) || 0; const swpEvent = this.draggedClone as SwpEventElement; const columnDate = new Date(dragColumnChangeEvent.newColumn.date); swpEvent.updatePosition(columnDate, currentTop); } } /** * Handle drag end event */ public handleDragEnd(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void { if (!draggedClone || !originalElement) { console.warn('Missing draggedClone or originalElement'); return; } // Fade out original this.fadeOutAndRemove(originalElement); // Remove clone prefix and normalize clone to be a regular event const cloneId = draggedClone.dataset.eventId; if (cloneId && cloneId.startsWith('clone-')) { draggedClone.dataset.eventId = cloneId.replace('clone-', ''); } // Fully normalize the clone to be a regular event draggedClone.classList.remove('dragging'); // Clean up instance state this.draggedClone = null; this.originalEvent = null; } /** * Handle navigation completed event */ public handleNavigationCompleted(): void { // Default implementation - can be overridden by subclasses } /** * Fade out and remove element */ private fadeOutAndRemove(element: HTMLElement): void { element.style.transition = 'opacity 0.3s ease-out'; element.style.opacity = '0'; setTimeout(() => { element.remove(); }, 300); } renderEvents(events: CalendarEvent[], container: HTMLElement): void { // Filter out all-day events - they should be handled by AllDayEventRenderer const timedEvents = events.filter(event => !event.allDay); // Find columns in the specific container for regular events const columns = this.getColumns(container); columns.forEach(column => { const columnEvents = this.getEventsForColumn(column, timedEvents); const eventsLayer = column.querySelector('swp-events-layer') as HTMLElement; if (eventsLayer) { this.renderColumnEvents(columnEvents, eventsLayer); } }); } /** * Render events in a column using combined stacking + grid algorithm */ private renderColumnEvents(columnEvents: CalendarEvent[], eventsLayer: HTMLElement): void { if (columnEvents.length === 0) return; console.log('[EventRenderer] Rendering column with', columnEvents.length, 'events'); // Step 1: Calculate stack levels for ALL events first (to understand overlaps) const allStackLinks = this.stackManager.createOptimizedStackLinks(columnEvents); console.log('[EventRenderer] All stack links:'); columnEvents.forEach(event => { const link = allStackLinks.get(event.id); console.log(` Event ${event.id} (${event.title}): stackLevel=${link?.stackLevel ?? 'none'}`); }); // Step 2: Find grid candidates (start together ±15 min) const groups = this.stackManager.groupEventsByStartTime(columnEvents); const gridGroups = groups.filter(group => { if (group.events.length <= 1) return false; group.containerType = this.stackManager.decideContainerType(group); return group.containerType === 'GRID'; }); console.log('[EventRenderer] Grid groups:', gridGroups.length); gridGroups.forEach((g, i) => { console.log(` Grid group ${i}:`, g.events.map(e => e.id)); }); // Step 3: Render grid groups and track which events have been rendered const renderedIds = new Set(); gridGroups.forEach((group, index) => { console.log(`[EventRenderer] Rendering grid group ${index} with ${group.events.length} events:`, group.events.map(e => e.id)); // Calculate grid group stack level by finding what it overlaps OUTSIDE the group const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks); console.log(` Grid group stack level: ${gridStackLevel}`); this.renderGridGroup(group, eventsLayer, gridStackLevel); group.events.forEach(e => renderedIds.add(e.id)); }); // Step 4: Get remaining events (not in grid) const remainingEvents = columnEvents.filter(e => !renderedIds.has(e.id)); console.log('[EventRenderer] Remaining events for stacking:'); remainingEvents.forEach(event => { const link = allStackLinks.get(event.id); console.log(` Event ${event.id} (${event.title}): stackLevel=${link?.stackLevel ?? 'none'}`); }); // Step 5: Render remaining stacked/single events remainingEvents.forEach(event => { const element = this.renderEvent(event); const stackLink = allStackLinks.get(event.id); console.log(`[EventRenderer] Rendering stacked event ${event.id}, stackLink:`, stackLink); if (stackLink) { // Apply stack link to element (for drag-drop) this.stackManager.applyStackLinkToElement(element, stackLink); // Apply visual styling this.stackManager.applyVisualStyling(element, stackLink.stackLevel); console.log(` Applied margin-left: ${stackLink.stackLevel * 15}px, stack-link:`, stackLink); } eventsLayer.appendChild(element); }); } /** * Calculate stack level for a grid group based on what it overlaps OUTSIDE the group */ private calculateGridGroupStackLevel( group: EventGroup, allEvents: CalendarEvent[], stackLinks: Map ): number { const groupEventIds = new Set(group.events.map(e => e.id)); // Find all events OUTSIDE this group const outsideEvents = allEvents.filter(e => !groupEventIds.has(e.id)); // Find the highest stackLevel of any event that overlaps with ANY event in the grid group let maxOverlappingLevel = -1; for (const gridEvent of group.events) { for (const outsideEvent of outsideEvents) { if (this.stackManager.doEventsOverlap(gridEvent, outsideEvent)) { const outsideLink = stackLinks.get(outsideEvent.id); if (outsideLink) { maxOverlappingLevel = Math.max(maxOverlappingLevel, outsideLink.stackLevel); } } } } // Grid group should be one level above the highest overlapping event return maxOverlappingLevel + 1; } /** * Render events in a grid container (side-by-side) */ private renderGridGroup(group: EventGroup, eventsLayer: HTMLElement, stackLevel: number): void { const groupElement = document.createElement('swp-event-group'); // Add grid column class based on event count const colCount = group.events.length; groupElement.classList.add(`cols-${colCount}`); // Add stack level class for margin-left offset groupElement.classList.add(`stack-level-${stackLevel}`); // Position based on earliest event const earliestEvent = group.events[0]; const position = this.calculateEventPosition(earliestEvent); groupElement.style.top = `${position.top + 1}px`; // Add z-index based on stack level groupElement.style.zIndex = `${this.stackManager.calculateZIndex(stackLevel)}`; // Add stack-link attribute for drag-drop (group acts as a stacked item) const stackLink: StackLink = { stackLevel: stackLevel // prev/next will be handled by drag-drop manager if needed }; this.stackManager.applyStackLinkToElement(groupElement, stackLink); // NO height on the group - it should auto-size based on children // Render each event within the grid group.events.forEach(event => { const element = this.renderEventInGrid(event, earliestEvent.start); groupElement.appendChild(element); }); eventsLayer.appendChild(groupElement); } /** * Render event within a grid container (relative positioning) */ private renderEventInGrid(event: CalendarEvent, containerStart: Date): HTMLElement { const element = SwpEventElement.fromCalendarEvent(event); // Calculate event height const position = this.calculateEventPosition(event); // Events in grid are positioned relatively - NO top offset needed // The grid container itself is positioned absolutely with the correct top element.style.position = 'relative'; element.style.height = `${position.height - 3}px`; return element; } private renderEvent(event: CalendarEvent): HTMLElement { const element = SwpEventElement.fromCalendarEvent(event); // Apply positioning (moved from SwpEventElement.applyPositioning) const position = this.calculateEventPosition(event); element.style.position = 'absolute'; element.style.top = `${position.top + 1}px`; element.style.height = `${position.height - 3}px`; element.style.left = '2px'; element.style.right = '2px'; return element; } protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } { // Delegate to PositionUtils for centralized position calculation return PositionUtils.calculateEventPosition(event.start, event.end); } clearEvents(container?: HTMLElement): void { const selector = 'swp-event'; const existingEvents = container ? container.querySelectorAll(selector) : document.querySelectorAll(selector); existingEvents.forEach(event => event.remove()); } protected getColumns(container: HTMLElement): HTMLElement[] { const columns = container.querySelectorAll('swp-day-column'); return Array.from(columns) as HTMLElement[]; } protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] { const columnDate = column.dataset.date; if (!columnDate) { return []; } const columnEvents = events.filter(event => { const eventDateStr = this.dateService.formatISODate(event.start); const matches = eventDateStr === columnDate; return matches; }); return columnEvents; } }