2025-10-06 00:24:13 +02:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2025-10-06 17:53:25 +02:00
|
|
|
import { calendarConfig } from '../core/CalendarConfig';
|
2025-10-06 00:24:13 +02:00
|
|
|
|
|
|
|
|
export interface GridGroupLayout {
|
|
|
|
|
events: CalendarEvent[];
|
|
|
|
|
stackLevel: number;
|
|
|
|
|
position: { top: number };
|
2025-10-06 17:05:18 +02:00
|
|
|
columns: CalendarEvent[][]; // Events grouped by column (events in same array share a column)
|
2025-10-06 00:24:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-06 17:53:25 +02:00
|
|
|
* Calculate complete layout for a column of events (recursive approach)
|
2025-10-06 00:24:13 +02:00
|
|
|
*/
|
|
|
|
|
public calculateColumnLayout(columnEvents: CalendarEvent[]): ColumnLayout {
|
|
|
|
|
if (columnEvents.length === 0) {
|
|
|
|
|
return { gridGroups: [], stackedEvents: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gridGroupLayouts: GridGroupLayout[] = [];
|
2025-10-06 17:53:25 +02:00
|
|
|
const stackedEventLayouts: StackedEventLayout[] = [];
|
|
|
|
|
const renderedEventsWithLevels: Array<{ event: CalendarEvent; 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
|
|
|
|
|
// (start within threshold AND overlap)
|
|
|
|
|
const gridSettings = calendarConfig.getGridSettings();
|
|
|
|
|
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
|
|
|
|
|
|
|
|
|
const gridCandidates = [firstEvent];
|
|
|
|
|
for (let i = 1; i < remaining.length; i++) {
|
|
|
|
|
const candidate = remaining[i];
|
|
|
|
|
const startDiff = Math.abs(candidate.start.getTime() - firstEvent.start.getTime()) / (1000 * 60);
|
|
|
|
|
|
|
|
|
|
// Only add if starts within threshold AND overlaps with firstEvent
|
|
|
|
|
if (startDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(firstEvent, candidate)) {
|
|
|
|
|
gridCandidates.push(candidate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decide: should this group be GRID or STACK?
|
|
|
|
|
const group: EventGroup = {
|
|
|
|
|
events: gridCandidates,
|
|
|
|
|
containerType: 'NONE',
|
|
|
|
|
startTime: firstEvent.start
|
2025-10-06 00:24:13 +02:00
|
|
|
};
|
2025-10-06 17:53:25 +02:00
|
|
|
const containerType = this.stackManager.decideContainerType(group);
|
|
|
|
|
|
|
|
|
|
if (containerType === 'GRID' && gridCandidates.length > 1) {
|
|
|
|
|
// Render as GRID
|
|
|
|
|
const gridStackLevel = this.calculateGridGroupStackLevelFromRendered(
|
|
|
|
|
gridCandidates,
|
|
|
|
|
renderedEventsWithLevels
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const earliestEvent = gridCandidates[0];
|
|
|
|
|
const position = 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 = 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-06 00:24:13 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
gridGroups: gridGroupLayouts,
|
|
|
|
|
stackedEvents: stackedEventLayouts
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-06 17:53:25 +02:00
|
|
|
* Calculate stack level for a grid group based on already rendered events
|
2025-10-06 00:24:13 +02:00
|
|
|
*/
|
2025-10-06 17:53:25 +02:00
|
|
|
private calculateGridGroupStackLevelFromRendered(
|
|
|
|
|
gridEvents: CalendarEvent[],
|
|
|
|
|
renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }>
|
2025-10-06 00:24:13 +02:00
|
|
|
): number {
|
2025-10-06 17:53:25 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-06 00:24:13 +02:00
|
|
|
|
2025-10-06 17:53:25 +02:00
|
|
|
return maxOverlappingLevel + 1;
|
|
|
|
|
}
|
2025-10-06 00:24:13 +02:00
|
|
|
|
2025-10-06 17:53:25 +02:00
|
|
|
/**
|
|
|
|
|
* Calculate stack level for a single stacked event based on already rendered events
|
|
|
|
|
*/
|
|
|
|
|
private calculateStackLevelFromRendered(
|
|
|
|
|
event: CalendarEvent,
|
|
|
|
|
renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }>
|
|
|
|
|
): number {
|
|
|
|
|
// Find highest stack level of any rendered event that overlaps with this event
|
2025-10-06 00:24:13 +02:00
|
|
|
let maxOverlappingLevel = -1;
|
|
|
|
|
|
2025-10-06 17:53:25 +02:00
|
|
|
for (const rendered of renderedEventsWithLevels) {
|
|
|
|
|
if (this.stackManager.doEventsOverlap(event, rendered.event)) {
|
|
|
|
|
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
|
2025-10-06 00:24:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return maxOverlappingLevel + 1;
|
|
|
|
|
}
|
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
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-10-06 00:24:13 +02:00
|
|
|
}
|