/** * EventLayoutEngine - Simplified stacking/grouping algorithm * * Supports two layout modes: * - GRID: Events starting at same time rendered side-by-side * - STACKING: Overlapping events with margin-left offset (15px per level) * * No prev/next chains, single-pass greedy algorithm */ import { ICalendarEvent } from '../../types/CalendarTypes'; import { IGridConfig } from '../../core/IGridConfig'; import { calculateEventPosition } from '../../utils/PositionUtils'; import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes'; /** * Check if two events overlap (strict - touching at boundary = NOT overlapping) * This matches Scenario 8: end===start is NOT overlap */ export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean { return a.start < b.end && a.end > b.start; } /** * Check if two events are within threshold for grid grouping. * This includes: * 1. Start-to-start: Events start within threshold of each other * 2. End-to-start: One event starts within threshold before another ends */ function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean { const thresholdMs = thresholdMinutes * 60 * 1000; // Start-to-start: both events start within threshold const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime()); if (startToStartDiff <= thresholdMs) return true; // End-to-start: one event starts within threshold before the other ends // B starts within threshold before A ends const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime(); if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true; // A starts within threshold before B ends const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime(); if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true; return false; } /** * Check if all events in a group start within threshold of each other */ function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean { if (events.length <= 1) return true; // Find earliest and latest start times let earliest = events[0].start.getTime(); let latest = events[0].start.getTime(); for (const event of events) { const time = event.start.getTime(); if (time < earliest) earliest = time; if (time > latest) latest = time; } const diffMinutes = (latest - earliest) / (1000 * 60); return diffMinutes <= thresholdMinutes; } /** * Find groups of overlapping events (connected by overlap chain) * Events are grouped if they overlap with any event in the group */ function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] { if (events.length === 0) return []; const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const used = new Set(); const groups: ICalendarEvent[][] = []; for (const event of sorted) { if (used.has(event.id)) continue; // Start a new group with this event const group: ICalendarEvent[] = [event]; used.add(event.id); // Expand group by finding all connected events (via overlap) let expanded = true; while (expanded) { expanded = false; for (const candidate of sorted) { if (used.has(candidate.id)) continue; // Check if candidate overlaps with any event in group const connects = group.some(member => eventsOverlap(member, candidate)); if (connects) { group.push(candidate); used.add(candidate.id); expanded = true; } } } groups.push(group); } return groups; } /** * Find grid candidates within a group - events connected via threshold chain * Uses V1 logic: events are connected if within threshold (no overlap requirement) */ function findGridCandidates( events: ICalendarEvent[], thresholdMinutes: number ): ICalendarEvent[][] { if (events.length === 0) return []; const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const used = new Set(); const groups: ICalendarEvent[][] = []; for (const event of sorted) { if (used.has(event.id)) continue; const group: ICalendarEvent[] = [event]; used.add(event.id); // Expand by threshold chain (V1 logic: no overlap requirement, just threshold) let expanded = true; while (expanded) { expanded = false; for (const candidate of sorted) { if (used.has(candidate.id)) continue; const connects = group.some(member => eventsWithinThreshold(member, candidate, thresholdMinutes) ); if (connects) { group.push(candidate); used.add(candidate.id); expanded = true; } } } groups.push(group); } return groups; } /** * Calculate stack levels for overlapping events using greedy algorithm * For each event: level = max(overlapping already-processed events) + 1 */ function calculateStackLevels(events: ICalendarEvent[]): Map { const levels = new Map(); const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); for (const event of sorted) { let maxOverlappingLevel = -1; // Find max level among overlapping events already processed for (const [id, level] of levels) { const other = events.find(e => e.id === id); if (other && eventsOverlap(event, other)) { maxOverlappingLevel = Math.max(maxOverlappingLevel, level); } } levels.set(event.id, maxOverlappingLevel + 1); } return levels; } /** * Allocate events to columns for GRID layout using greedy algorithm * Non-overlapping events can share a column to minimize total columns */ function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] { const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const columns: ICalendarEvent[][] = []; for (const event of sorted) { // Find first column where event doesn't overlap with existing events let placed = false; for (const column of columns) { const canFit = !column.some(e => eventsOverlap(event, e)); if (canFit) { column.push(event); placed = true; break; } } // No suitable column found, create new one if (!placed) { columns.push([event]); } } return columns; } /** * Main entry point: Calculate complete layout for a column's events * * Algorithm: * 1. Find overlap groups (events connected by overlap chain) * 2. For each overlap group, find grid candidates (events within threshold chain) * 3. If all events in overlap group form a single grid candidate → GRID mode * 4. Otherwise → STACKING mode with calculated levels */ export function calculateColumnLayout( events: ICalendarEvent[], config: IGridConfig ): IColumnLayout { const thresholdMinutes = config.gridStartThresholdMinutes ?? 10; const result: IColumnLayout = { grids: [], stacked: [] }; if (events.length === 0) return result; // Find all overlapping event groups const overlapGroups = findOverlapGroups(events); for (const overlapGroup of overlapGroups) { if (overlapGroup.length === 1) { // Single event - no grouping needed result.stacked.push({ event: overlapGroup[0], stackLevel: 0 }); continue; } // Within this overlap group, find grid candidates (threshold-connected subgroups) const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes); // Check if the ENTIRE overlap group forms a single grid candidate // This happens when all events are connected via threshold chain const largestGridCandidate = gridSubgroups.reduce((max, g) => g.length > max.length ? g : max, gridSubgroups[0]); if (largestGridCandidate.length === overlapGroup.length) { // All events in overlap group are connected via threshold chain → GRID mode const columns = allocateColumns(overlapGroup); const earliest = overlapGroup.reduce((min, e) => e.start < min.start ? e : min, overlapGroup[0]); const position = calculateEventPosition(earliest.start, earliest.end, config); result.grids.push({ events: overlapGroup, columns, stackLevel: 0, position: { top: position.top } }); } else { // Not all events connected via threshold → STACKING mode const levels = calculateStackLevels(overlapGroup); for (const event of overlapGroup) { result.stacked.push({ event, stackLevel: levels.get(event.id) ?? 0 }); } } } return result; }