/** * EventStackManager - Manages visual stacking of overlapping calendar events * * This class handles the creation and maintenance of "stack chains" - doubly-linked * lists of overlapping events stored directly in DOM elements via data attributes. * * Implements 3-phase algorithm for grid + nested stacking: * Phase 1: Group events by start time proximity (configurable threshold) * Phase 2: Decide container type (GRID vs STACKING) * Phase 3: Handle late arrivals (nested stacking - NOT IMPLEMENTED) * * @see STACKING_CONCEPT.md for detailed documentation * @see stacking-visualization.html for visual examples */ import { ICalendarEvent } from '../types/CalendarTypes'; import { Configuration } from '../configurations/CalendarConfig'; export interface IStackLink { prev?: string; // Event ID of previous event in stack next?: string; // Event ID of next event in stack stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.) } export interface IEventGroup { events: ICalendarEvent[]; containerType: 'NONE' | 'GRID' | 'STACKING'; startTime: Date; } export class EventStackManager { private static readonly STACK_OFFSET_PX = 15; private config: Configuration; constructor(config: Configuration) { this.config = config; } // ============================================ // PHASE 1: Start Time Grouping // ============================================ /** * Group events by time conflicts (both start-to-start and end-to-start within threshold) * * Events are grouped if: * 1. They start within ±threshold minutes of each other (start-to-start) * 2. One event starts within threshold minutes before another ends (end-to-start conflict) */ public groupEventsByStartTime(events: ICalendarEvent[]): IEventGroup[] { if (events.length === 0) return []; // Get threshold from config const gridSettings = this.config.gridSettings; const thresholdMinutes = gridSettings.gridStartThresholdMinutes; // Sort events by start time const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const groups: IEventGroup[] = []; for (const event of sorted) { // Find existing group that this event conflicts with const existingGroup = groups.find(group => { // Check if event conflicts with ANY event in the group return group.events.some(groupEvent => { // Start-to-start conflict: events start within threshold const startToStartMinutes = Math.abs(event.start.getTime() - groupEvent.start.getTime()) / (1000 * 60); if (startToStartMinutes <= thresholdMinutes) { return true; } // End-to-start conflict: event starts within threshold before groupEvent ends const endToStartMinutes = (groupEvent.end.getTime() - event.start.getTime()) / (1000 * 60); if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) { return true; } // Also check reverse: groupEvent starts within threshold before event ends const reverseEndToStart = (event.end.getTime() - groupEvent.start.getTime()) / (1000 * 60); if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) { return true; } return false; }); }); if (existingGroup) { existingGroup.events.push(event); } else { groups.push({ events: [event], containerType: 'NONE', startTime: event.start }); } } return groups; } // ============================================ // PHASE 2: Container Type Decision // ============================================ /** * Decide container type for a group of events * * Rule: Events starting simultaneously (within threshold) should ALWAYS use GRID, * even if they overlap each other. This provides better visual indication that * events start at the same time. */ public decideContainerType(group: IEventGroup): 'NONE' | 'GRID' | 'STACKING' { if (group.events.length === 1) { return 'NONE'; } // If events are grouped together (start within threshold), they should share columns (GRID) // This is true EVEN if they overlap, because the visual priority is to show // that they start simultaneously. return 'GRID'; } /** * Check if two events overlap in time */ public doEventsOverlap(event1: ICalendarEvent, event2: ICalendarEvent): boolean { return event1.start < event2.end && event1.end > event2.start; } // ============================================ // Stack Level Calculation // ============================================ /** * Create optimized stack links (events share levels when possible) */ public createOptimizedStackLinks(events: ICalendarEvent[]): Map { const stackLinks = new Map(); if (events.length === 0) return stackLinks; // Sort by start time const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); // Step 1: Assign stack levels for (const event of sorted) { // Find all events this event overlaps with const overlapping = sorted.filter(other => other !== event && this.doEventsOverlap(event, other) ); // Find the MINIMUM required level (must be above all overlapping events) let minRequiredLevel = 0; for (const other of overlapping) { const otherLink = stackLinks.get(other.id); if (otherLink) { // Must be at least one level above the overlapping event minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1); } } stackLinks.set(event.id, { stackLevel: minRequiredLevel }); } // Step 2: Build prev/next chains for overlapping events at adjacent stack levels for (const event of sorted) { const currentLink = stackLinks.get(event.id)!; // Find overlapping events that are directly below (stackLevel - 1) const overlapping = sorted.filter(other => other !== event && this.doEventsOverlap(event, other) ); const directlyBelow = overlapping.filter(other => { const otherLink = stackLinks.get(other.id); return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1; }); if (directlyBelow.length > 0) { // Use the first one in sorted order as prev currentLink.prev = directlyBelow[0].id; } // Find overlapping events that are directly above (stackLevel + 1) const directlyAbove = overlapping.filter(other => { const otherLink = stackLinks.get(other.id); return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1; }); if (directlyAbove.length > 0) { // Use the first one in sorted order as next currentLink.next = directlyAbove[0].id; } } return stackLinks; } /** * Calculate marginLeft based on stack level */ public calculateMarginLeft(stackLevel: number): number { return stackLevel * EventStackManager.STACK_OFFSET_PX; } /** * Calculate zIndex based on stack level */ public calculateZIndex(stackLevel: number): number { return 100 + stackLevel; } /** * Serialize stack link to JSON string */ public serializeStackLink(stackLink: IStackLink): string { return JSON.stringify(stackLink); } /** * Deserialize JSON string to stack link */ public deserializeStackLink(json: string): IStackLink | null { try { return JSON.parse(json); } catch (e) { return null; } } /** * Apply stack link to DOM element */ public applyStackLinkToElement(element: HTMLElement, stackLink: IStackLink): void { element.dataset.stackLink = this.serializeStackLink(stackLink); } /** * Get stack link from DOM element */ public getStackLinkFromElement(element: HTMLElement): IStackLink | null { const data = element.dataset.stackLink; if (!data) return null; return this.deserializeStackLink(data); } /** * Apply visual styling to element based on stack level */ public applyVisualStyling(element: HTMLElement, stackLevel: number): void { element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`; element.style.zIndex = `${this.calculateZIndex(stackLevel)}`; } /** * Clear stack link from element */ public clearStackLinkFromElement(element: HTMLElement): void { delete element.dataset.stackLink; } /** * Clear visual styling from element */ public clearVisualStyling(element: HTMLElement): void { element.style.marginLeft = ''; element.style.zIndex = ''; } }