Calendar/src/managers/EventLayoutCoordinator.ts

281 lines
9.5 KiB
TypeScript
Raw Normal View History

/**
* EventLayoutCoordinator - Coordinates event layout calculations
*
* Separates layout logic from rendering concerns.
* Calculates stack levels, groups events, and determines rendering strategy.
*/
2025-11-03 21:30:50 +01:00
import { ICalendarEvent } from '../types/CalendarTypes';
import { EventStackManager, IEventGroup, IStackLink } from './EventStackManager';
import { PositionUtils } from '../utils/PositionUtils';
2025-11-03 22:04:37 +01:00
import { Configuration } from '../configurations/CalendarConfig';
2025-11-03 21:30:50 +01:00
export interface IGridGroupLayout {
events: ICalendarEvent[];
stackLevel: number;
position: { top: number };
2025-11-03 21:30:50 +01:00
columns: ICalendarEvent[][]; // Events grouped by column (events in same array share a column)
}
2025-11-03 21:30:50 +01:00
export interface IStackedEventLayout {
event: ICalendarEvent;
stackLink: IStackLink;
position: { top: number; height: number };
}
2025-11-03 21:30:50 +01:00
export interface IColumnLayout {
gridGroups: IGridGroupLayout[];
stackedEvents: IStackedEventLayout[];
}
export class EventLayoutCoordinator {
private stackManager: EventStackManager;
2025-11-03 21:30:50 +01:00
private config: Configuration;
private positionUtils: PositionUtils;
2025-11-03 21:30:50 +01:00
constructor(stackManager: EventStackManager, config: Configuration, positionUtils: PositionUtils) {
this.stackManager = stackManager;
this.config = config;
this.positionUtils = positionUtils;
}
/**
* Calculate complete layout for a column of events (recursive approach)
*/
2025-11-03 21:30:50 +01:00
public calculateColumnLayout(columnEvents: ICalendarEvent[]): IColumnLayout {
if (columnEvents.length === 0) {
return { gridGroups: [], stackedEvents: [] };
}
2025-11-03 21:30:50 +01:00
const gridGroupLayouts: IGridGroupLayout[] = [];
const stackedEventLayouts: IStackedEventLayout[] = [];
const renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }> = [];
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)
2025-11-03 22:04:37 +01:00
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?
2025-11-03 21:30:50 +01:00
const group: IEventGroup = {
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
*/
private calculateGridGroupStackLevelFromRendered(
2025-11-03 21:30:50 +01:00
gridEvents: ICalendarEvent[],
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
): number {
// 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
*/
private calculateStackLevelFromRendered(
2025-11-03 21:30:50 +01:00
event: ICalendarEvent,
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
): number {
// 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;
}
2025-10-06 17:05:18 +02:00
/**
* 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
*/
2025-11-03 21:30:50 +01:00
private detectConflict(event1: ICalendarEvent, event2: ICalendarEvent, thresholdMinutes: number): boolean {
// 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 (ABC 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
*/
private expandGridCandidates(
2025-11-03 21:30:50 +01:00
firstEvent: ICalendarEvent,
remaining: ICalendarEvent[],
thresholdMinutes: number
2025-11-03 21:30:50 +01:00
): ICalendarEvent[] {
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;
}
2025-10-06 17:05:18 +02:00
/**
* 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
*/
2025-11-03 21:30:50 +01:00
private allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
2025-10-06 17:05:18 +02:00
if (events.length === 0) return [];
if (events.length === 1) return [[events[0]]];
2025-11-03 21:30:50 +01:00
const columns: ICalendarEvent[][] = [];
2025-10-06 17:05:18 +02:00
// 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;
}
}