/** * EventLayoutCoordinator - Coordinates event layout calculations * * Separates layout logic from rendering concerns. * Calculates stack levels, groups events, and determines rendering strategy. */ import { CalendarEvent } from '../types/CalendarTypes'; import { EventStackManager, EventGroup, StackLink } from './EventStackManager'; import { PositionUtils } from '../utils/PositionUtils'; export interface GridGroupLayout { events: CalendarEvent[]; stackLevel: number; position: { top: number }; columns: CalendarEvent[][]; // Events grouped by column (events in same array share a column) } export interface StackedEventLayout { event: CalendarEvent; stackLink: StackLink; position: { top: number; height: number }; } export interface ColumnLayout { gridGroups: GridGroupLayout[]; stackedEvents: StackedEventLayout[]; } export class EventLayoutCoordinator { private stackManager: EventStackManager; constructor() { this.stackManager = new EventStackManager(); } /** * Calculate complete layout for a column of events */ public calculateColumnLayout(columnEvents: CalendarEvent[]): ColumnLayout { if (columnEvents.length === 0) { return { gridGroups: [], stackedEvents: [] }; } // Step 1: Calculate stack levels for ALL events first (to understand overlaps) const allStackLinks = this.stackManager.createOptimizedStackLinks(columnEvents); // 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'; }); // Step 3: Build grid group layouts const gridGroupLayouts: GridGroupLayout[] = []; const renderedEventIds = new Set(); gridGroups.forEach(group => { const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks); const earliestEvent = group.events[0]; const position = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end); const columns = this.allocateColumns(group.events); gridGroupLayouts.push({ events: group.events, stackLevel: gridStackLevel, position: { top: position.top + 1 }, columns }); group.events.forEach(e => renderedEventIds.add(e.id)); }); // Step 4: Build stacked event layouts for remaining events const remainingEvents = columnEvents.filter(e => !renderedEventIds.has(e.id)); const stackedEventLayouts: StackedEventLayout[] = remainingEvents.map(event => { const stackLink = allStackLinks.get(event.id)!; const position = PositionUtils.calculateEventPosition(event.start, event.end); return { event, stackLink, position: { top: position.top + 1, height: position.height - 3 } }; }); return { gridGroups: gridGroupLayouts, stackedEvents: stackedEventLayouts }; } /** * 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; } /** * 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 */ private allocateColumns(events: CalendarEvent[]): CalendarEvent[][] { if (events.length === 0) return []; if (events.length === 1) return [[events[0]]]; const columns: CalendarEvent[][] = []; // 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; } }