/** * EventLayoutCoordinator - Coordinates event layout calculations * * Separates layout logic from rendering concerns. * Calculates stack levels, groups events, and determines rendering strategy. */ export class EventLayoutCoordinator { constructor(stackManager, config, positionUtils) { this.stackManager = stackManager; this.config = config; this.positionUtils = positionUtils; } /** * Calculate complete layout for a column of events (recursive approach) */ calculateColumnLayout(columnEvents) { if (columnEvents.length === 0) { return { gridGroups: [], stackedEvents: [] }; } const gridGroupLayouts = []; const stackedEventLayouts = []; const renderedEventsWithLevels = []; let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime()); // Process events recursively while (remaining.length > 0) { // Take first event const firstEvent = remaining[0]; // Find events that could be in GRID with first event // Use expanding search to find chains (A→B→C where each conflicts with next) const gridSettings = this.config.gridSettings; const thresholdMinutes = gridSettings.gridStartThresholdMinutes; // Use refactored method for expanding grid candidates const gridCandidates = this.expandGridCandidates(firstEvent, remaining, thresholdMinutes); // Decide: should this group be GRID or STACK? const group = { events: gridCandidates, containerType: 'NONE', startTime: firstEvent.start }; const containerType = this.stackManager.decideContainerType(group); if (containerType === 'GRID' && gridCandidates.length > 1) { // Render as GRID const gridStackLevel = this.calculateGridGroupStackLevelFromRendered(gridCandidates, renderedEventsWithLevels); // Ensure we get the earliest event (explicit sort for robustness) const earliestEvent = [...gridCandidates].sort((a, b) => a.start.getTime() - b.start.getTime())[0]; const position = this.positionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end); const columns = this.allocateColumns(gridCandidates); gridGroupLayouts.push({ events: gridCandidates, stackLevel: gridStackLevel, position: { top: position.top + 1 }, columns }); // Mark all events in grid with their stack level gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel })); // Remove all events in this grid from remaining remaining = remaining.filter(e => !gridCandidates.includes(e)); } else { // Render first event as STACKED const stackLevel = this.calculateStackLevelFromRendered(firstEvent, renderedEventsWithLevels); const position = this.positionUtils.calculateEventPosition(firstEvent.start, firstEvent.end); stackedEventLayouts.push({ event: firstEvent, stackLink: { stackLevel }, position: { top: position.top + 1, height: position.height - 3 } }); // Mark this event with its stack level renderedEventsWithLevels.push({ event: firstEvent, level: stackLevel }); // Remove only first event from remaining remaining = remaining.slice(1); } } return { gridGroups: gridGroupLayouts, stackedEvents: stackedEventLayouts }; } /** * Calculate stack level for a grid group based on already rendered events */ calculateGridGroupStackLevelFromRendered(gridEvents, renderedEventsWithLevels) { // Find highest stack level of any rendered event that overlaps with this grid let maxOverlappingLevel = -1; for (const gridEvent of gridEvents) { for (const rendered of renderedEventsWithLevels) { if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) { maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level); } } } return maxOverlappingLevel + 1; } /** * Calculate stack level for a single stacked event based on already rendered events */ calculateStackLevelFromRendered(event, renderedEventsWithLevels) { // Find highest stack level of any rendered event that overlaps with this event let maxOverlappingLevel = -1; for (const rendered of renderedEventsWithLevels) { if (this.stackManager.doEventsOverlap(event, rendered.event)) { maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level); } } return maxOverlappingLevel + 1; } /** * Detect if two events have a conflict based on threshold * * @param event1 - First event * @param event2 - Second event * @param thresholdMinutes - Threshold in minutes * @returns true if events conflict */ detectConflict(event1, event2, thresholdMinutes) { // Check 1: Start-to-start conflict (starts within threshold) const startToStartDiff = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60); if (startToStartDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(event1, event2)) { return true; } // Check 2: End-to-start conflict (event1 starts within threshold before event2 ends) const endToStartMinutes = (event2.end.getTime() - event1.start.getTime()) / (1000 * 60); if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) { return true; } // Check 3: Reverse end-to-start (event2 starts within threshold before event1 ends) const reverseEndToStart = (event1.end.getTime() - event2.start.getTime()) / (1000 * 60); if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) { return true; } return false; } /** * Expand grid candidates to find all events connected by conflict chains * * Uses expanding search to find chains (A→B→C where each conflicts with next) * * @param firstEvent - The first event to start with * @param remaining - Remaining events to check * @param thresholdMinutes - Threshold in minutes * @returns Array of all events in the conflict chain */ expandGridCandidates(firstEvent, remaining, thresholdMinutes) { const gridCandidates = [firstEvent]; let candidatesChanged = true; // Keep expanding until no new candidates can be added while (candidatesChanged) { candidatesChanged = false; for (let i = 1; i < remaining.length; i++) { const candidate = remaining[i]; // Skip if already in candidates if (gridCandidates.includes(candidate)) continue; // Check if candidate conflicts with ANY event in gridCandidates for (const existingCandidate of gridCandidates) { if (this.detectConflict(candidate, existingCandidate, thresholdMinutes)) { gridCandidates.push(candidate); candidatesChanged = true; break; // Found conflict, move to next candidate } } } } return gridCandidates; } /** * Allocate events to columns within a grid group * * Events that don't overlap can share the same column. * Uses a greedy algorithm to minimize the number of columns. * * @param events - Events in the grid group (should already be sorted by start time) * @returns Array of columns, where each column is an array of events */ allocateColumns(events) { if (events.length === 0) return []; if (events.length === 1) return [[events[0]]]; const columns = []; // For each event, try to place it in an existing column where it doesn't overlap for (const event of events) { let placed = false; // Try to find a column where this event doesn't overlap with any existing event for (const column of columns) { const hasOverlap = column.some(colEvent => this.stackManager.doEventsOverlap(event, colEvent)); if (!hasOverlap) { column.push(event); placed = true; break; } } // If no suitable column found, create a new one if (!placed) { columns.push([event]); } } return columns; } } //# sourceMappingURL=EventLayoutCoordinator.js.map