/** * SimpleEventOverlapManager - Clean, focused overlap management * Eliminates complex state tracking in favor of direct DOM manipulation */ import { CalendarEvent } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; export enum OverlapType { NONE = 'none', COLUMN_SHARING = 'column_sharing', STACKING = 'stacking' } export interface OverlapGroup { type: OverlapType; events: CalendarEvent[]; position: { top: number; height: number }; } export interface StackLink { prev?: string; // Event ID of previous event in stack next?: string; // Event ID of next event in stack stackLevel: number; // 0 = base event, 1 = first stacked, etc } export class SimpleEventOverlapManager { private static readonly STACKING_WIDTH_REDUCTION_PX = 15; /** * Detect overlap type between two DOM elements - pixel-based logic */ public resolveOverlapType(element1: HTMLElement, element2: HTMLElement): OverlapType { const top1 = parseInt(element1.style.top) || 0; const height1 = parseInt(element1.style.height) || 0; const bottom1 = top1 + height1; const top2 = parseInt(element2.style.top) || 0; const height2 = parseInt(element2.style.height) || 0; const bottom2 = top2 + height2; // Check if events overlap in pixel space const tolerance = 2; if (bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance)) { return OverlapType.NONE; } // Events overlap - check start position difference for overlap type const startDifference = Math.abs(top1 - top2); // Over 40px start difference = stacking if (startDifference > 40) { return OverlapType.STACKING; } // Within 40px start difference = column sharing return OverlapType.COLUMN_SHARING; } /** * Group overlapping elements - pixel-based algorithm */ public groupOverlappingElements(elements: HTMLElement[]): HTMLElement[][] { const groups: HTMLElement[][] = []; const processed = new Set(); for (const element of elements) { if (processed.has(element)) continue; // Find all elements that overlap with this one const overlapping = elements.filter(other => { if (processed.has(other)) return false; return other === element || this.resolveOverlapType(element, other) !== OverlapType.NONE; }); // Mark all as processed overlapping.forEach(e => processed.add(e)); groups.push(overlapping); } return groups; } /** * Create flexbox container for column sharing - clean and simple */ public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement { const container = document.createElement('swp-event-group'); return container; } /** * Add event to flexbox group - simple relative positioning */ public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void { // Set duration-based height const duration = eventElement.dataset.duration; if (duration) { const durationMinutes = parseInt(duration); const gridSettings = calendarConfig.getGridSettings(); const height = (durationMinutes / 60) * gridSettings.hourHeight; eventElement.style.height = `${height - 3}px`; } // Flexbox styling eventElement.style.position = 'relative'; eventElement.style.flex = '1'; eventElement.style.minWidth = '50px'; container.appendChild(eventElement); } /** * Create stacked event with data-attribute tracking */ public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number): void { const marginLeft = stackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; // Apply visual styling eventElement.style.marginLeft = `${marginLeft}px`; eventElement.style.left = '2px'; eventElement.style.right = '2px'; eventElement.style.zIndex = `${100 + stackLevel}`; // Set up stack linking via data attributes const eventId = eventElement.dataset.eventId; const underlyingId = underlyingElement.dataset.eventId; if (!eventId || !underlyingId) { console.warn('Missing event IDs for stack linking:', eventId, underlyingId); return; } // Find the last event in the stack chain let lastElement = underlyingElement; let lastLink = this.getStackLink(lastElement); // If underlying doesn't have stack link yet, create it if (!lastLink) { this.setStackLink(lastElement, { stackLevel: 0 }); lastLink = { stackLevel: 0 }; } // Traverse to find the end of the chain while (lastLink?.next) { const nextElement = this.findElementById(lastLink.next); if (!nextElement) break; lastElement = nextElement; lastLink = this.getStackLink(lastElement); } // Link the new event to the end of the chain const lastElementId = lastElement.dataset.eventId!; this.setStackLink(lastElement, { ...lastLink!, next: eventId }); this.setStackLink(eventElement, { prev: lastElementId, stackLevel: stackLevel }); } /** * Remove stacked styling with proper stack re-linking */ public removeStackedStyling(eventElement: HTMLElement): void { // Clear visual styling eventElement.style.marginLeft = ''; eventElement.style.zIndex = ''; eventElement.style.left = '2px'; eventElement.style.right = '2px'; // Handle stack chain re-linking const link = this.getStackLink(eventElement); if (link) { // Re-link prev and next events if (link.prev && link.next) { // Middle element - link prev to next const prevElement = this.findElementById(link.prev); const nextElement = this.findElementById(link.next); if (prevElement && nextElement) { const prevLink = this.getStackLink(prevElement); const nextLink = this.getStackLink(nextElement); // CRITICAL: Check if prev and next actually overlap without the middle element const actuallyOverlap = this.resolveOverlapType(prevElement, nextElement); if (!actuallyOverlap) { // CHAIN BREAKING: prev and next don't overlap - break the chain console.log('Breaking stack chain - events do not overlap directly'); // Prev element: remove next link (becomes end of its own chain) this.setStackLink(prevElement, { ...prevLink!, next: undefined }); // Next element: becomes standalone (remove all stack links and styling) this.setStackLink(nextElement, null); nextElement.style.marginLeft = ''; nextElement.style.zIndex = ''; // If next element had subsequent events, they also become standalone if (nextLink?.next) { let subsequentId: string | undefined = nextLink.next; while (subsequentId) { const subsequentElement = this.findElementById(subsequentId); if (!subsequentElement) break; const subsequentLink = this.getStackLink(subsequentElement); this.setStackLink(subsequentElement, null); subsequentElement.style.marginLeft = ''; subsequentElement.style.zIndex = ''; subsequentId = subsequentLink?.next; } } } else { // NORMAL STACKING: they overlap, maintain the chain this.setStackLink(prevElement, { ...prevLink!, next: link.next }); const correctStackLevel = (prevLink?.stackLevel ?? 0) + 1; this.setStackLink(nextElement, { ...nextLink!, prev: link.prev, stackLevel: correctStackLevel }); // Update visual styling to match new stackLevel const marginLeft = correctStackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; nextElement.style.marginLeft = `${marginLeft}px`; nextElement.style.zIndex = `${100 + correctStackLevel}`; } } } else if (link.prev) { // Last element - remove next link from prev const prevElement = this.findElementById(link.prev); if (prevElement) { const prevLink = this.getStackLink(prevElement); this.setStackLink(prevElement, { ...prevLink!, next: undefined }); } } else if (link.next) { // First element - remove prev link from next const nextElement = this.findElementById(link.next); if (nextElement) { const nextLink = this.getStackLink(nextElement); this.setStackLink(nextElement, { ...nextLink!, prev: undefined, stackLevel: 0 // Next becomes the base event }); } } // Only update subsequent stack levels if we didn't break the chain if (link.prev && link.next) { const nextElement = this.findElementById(link.next); const nextLink = nextElement ? this.getStackLink(nextElement) : null; // If next element still has a stack link, the chain wasn't broken if (nextLink && nextLink.next) { this.updateSubsequentStackLevels(nextLink.next, -1); } // If nextLink is null, chain was broken - no subsequent updates needed } else { // First or last removal - update all subsequent this.updateSubsequentStackLevels(link.next, -1); } // Clear this element's stack link this.setStackLink(eventElement, null); } } /** * Update stack levels for all events following a given event ID */ private updateSubsequentStackLevels(startEventId: string | undefined, levelDelta: number): void { let currentId = startEventId; while (currentId) { const currentElement = this.findElementById(currentId); if (!currentElement) break; const currentLink = this.getStackLink(currentElement); if (!currentLink) break; // Update stack level const newLevel = Math.max(0, currentLink.stackLevel + levelDelta); this.setStackLink(currentElement, { ...currentLink, stackLevel: newLevel }); // Update visual styling const marginLeft = newLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; currentElement.style.marginLeft = `${marginLeft}px`; currentElement.style.zIndex = `${100 + newLevel}`; currentId = currentLink.next; } } /** * Check if element is stacked - check both style and data-stack-link */ public isStackedEvent(element: HTMLElement): boolean { const marginLeft = element.style.marginLeft; const hasMarginLeft = marginLeft !== '' && marginLeft !== '0px'; const hasStackLink = this.getStackLink(element) !== null; return hasMarginLeft || hasStackLink; } /** * Remove event from group with proper cleanup */ public removeFromEventGroup(container: HTMLElement, eventId: string): boolean { const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement; if (!eventElement) return false; // Simply remove the element - no position calculation needed since it's being removed eventElement.remove(); // Handle remaining events const remainingEvents = container.querySelectorAll('swp-event'); const remainingCount = remainingEvents.length; if (remainingCount === 0) { container.remove(); return true; } if (remainingCount === 1) { const remainingEvent = remainingEvents[0] as HTMLElement; // Convert last event back to absolute positioning - use current pixel position const currentTop = parseInt(remainingEvent.style.top) || 0; remainingEvent.style.position = 'absolute'; remainingEvent.style.top = `${currentTop}px`; remainingEvent.style.left = '2px'; remainingEvent.style.right = '2px'; remainingEvent.style.flex = ''; remainingEvent.style.minWidth = ''; container.parentElement?.insertBefore(remainingEvent, container); container.remove(); return true; } return false; } /** * Restack events in container - respects separate stack chains */ public restackEventsInContainer(container: HTMLElement): void { const stackedEvents = Array.from(container.querySelectorAll('swp-event')) .filter(el => this.isStackedEvent(el as HTMLElement)) as HTMLElement[]; if (stackedEvents.length === 0) return; // Group events by their stack chains const processedEventIds = new Set(); const stackChains: HTMLElement[][] = []; for (const element of stackedEvents) { const eventId = element.dataset.eventId; if (!eventId || processedEventIds.has(eventId)) continue; // Find the root of this stack chain (stackLevel 0 or no prev link) let rootElement = element; let rootLink = this.getStackLink(rootElement); while (rootLink?.prev) { const prevElement = this.findElementById(rootLink.prev); if (!prevElement) break; rootElement = prevElement; rootLink = this.getStackLink(rootElement); } // Collect all elements in this chain const chain: HTMLElement[] = []; let currentElement = rootElement; while (currentElement) { chain.push(currentElement); processedEventIds.add(currentElement.dataset.eventId!); const currentLink = this.getStackLink(currentElement); if (!currentLink?.next) break; const nextElement = this.findElementById(currentLink.next); if (!nextElement) break; currentElement = nextElement; } if (chain.length > 1) { // Only add chains with multiple events stackChains.push(chain); } } // Re-stack each chain separately stackChains.forEach(chain => { chain.forEach((element, index) => { const marginLeft = index * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; element.style.marginLeft = `${marginLeft}px`; element.style.zIndex = `${100 + index}`; // Update the data-stack-link with correct stackLevel const link = this.getStackLink(element); if (link) { this.setStackLink(element, { ...link, stackLevel: index }); } }); }); } /** * Utility methods - simple DOM traversal */ public getEventGroup(eventElement: HTMLElement): HTMLElement | null { return eventElement.closest('swp-event-group') as HTMLElement; } public isInEventGroup(element: HTMLElement): boolean { return this.getEventGroup(element) !== null; } /** * Helper methods for data-attribute based stack tracking */ public getStackLink(element: HTMLElement): StackLink | null { const linkData = element.dataset.stackLink; if (!linkData) return null; try { return JSON.parse(linkData); } catch (e) { console.warn('Failed to parse stack link data:', linkData, e); return null; } } private setStackLink(element: HTMLElement, link: StackLink | null): void { if (link === null) { delete element.dataset.stackLink; } else { element.dataset.stackLink = JSON.stringify(link); } } private findElementById(eventId: string): HTMLElement | null { return document.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement; } }