166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
|
|
/**
|
||
|
|
* AllDayLayoutEngine - Pure data-driven layout calculation for all-day events
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
||
|
|
|
||
|
|
export interface EventLayout {
|
||
|
|
id: string;
|
||
|
|
gridArea: string; // "row-start / col-start / row-end / col-end"
|
||
|
|
startColumn: number;
|
||
|
|
endColumn: number;
|
||
|
|
row: number;
|
||
|
|
columnSpan: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class AllDayLayoutEngine {
|
||
|
|
private weekDates: string[];
|
||
|
|
|
||
|
|
constructor(weekDates: string[]) {
|
||
|
|
this.weekDates = weekDates;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate layout for all events with proper overlap detection
|
||
|
|
*/
|
||
|
|
public calculateLayout(events: CalendarEvent[]): Map<string, EventLayout> {
|
||
|
|
const layouts = new Map<string, EventLayout>();
|
||
|
|
|
||
|
|
// Sort by event duration (longest first), then by start date
|
||
|
|
const sortedEvents = [...events].sort((a, b) => {
|
||
|
|
const durationA = this.calculateEventDuration(a);
|
||
|
|
const durationB = this.calculateEventDuration(b);
|
||
|
|
|
||
|
|
// Primary sort: longest duration first
|
||
|
|
if (durationA !== durationB) {
|
||
|
|
return durationB - durationA;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Secondary sort: earliest start date first
|
||
|
|
const startA = a.start.toISOString().split('T')[0];
|
||
|
|
const startB = b.start.toISOString().split('T')[0];
|
||
|
|
return startA.localeCompare(startB);
|
||
|
|
});
|
||
|
|
|
||
|
|
sortedEvents.forEach(event => {
|
||
|
|
const layout = this.calculateEventLayout(event, layouts);
|
||
|
|
layouts.set(event.id, layout);
|
||
|
|
});
|
||
|
|
|
||
|
|
return layouts;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate event duration in days
|
||
|
|
*/
|
||
|
|
private calculateEventDuration(event: CalendarEvent): number {
|
||
|
|
const startDate = event.start;
|
||
|
|
const endDate = event.end;
|
||
|
|
const diffTime = endDate.getTime() - startDate.getTime();
|
||
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 because same day = 1 day
|
||
|
|
return diffDays;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate layout for single event considering existing events
|
||
|
|
*/
|
||
|
|
private calculateEventLayout(event: CalendarEvent, existingLayouts: Map<string, EventLayout>): EventLayout {
|
||
|
|
// Calculate column span
|
||
|
|
const { startColumn, endColumn, columnSpan } = this.calculateColumnSpan(event);
|
||
|
|
|
||
|
|
// Find available row using overlap detection
|
||
|
|
const availableRow = this.findAvailableRow(startColumn, endColumn, existingLayouts);
|
||
|
|
|
||
|
|
// Generate grid-area string: "row-start / col-start / row-end / col-end"
|
||
|
|
const gridArea = `${availableRow} / ${startColumn} / ${availableRow + 1} / ${endColumn + 1}`;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: event.id,
|
||
|
|
gridArea,
|
||
|
|
startColumn,
|
||
|
|
endColumn,
|
||
|
|
row: availableRow,
|
||
|
|
columnSpan
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate column span based on event start and end dates
|
||
|
|
*/
|
||
|
|
private calculateColumnSpan(event: CalendarEvent): { startColumn: number; endColumn: number; columnSpan: number } {
|
||
|
|
// Convert CalendarEvent dates to YYYY-MM-DD format
|
||
|
|
const startDate = event.start.toISOString().split('T')[0];
|
||
|
|
const endDate = event.end.toISOString().split('T')[0];
|
||
|
|
// Find start and end column indices (1-based)
|
||
|
|
let startColumn = -1;
|
||
|
|
let endColumn = -1;
|
||
|
|
|
||
|
|
this.weekDates.forEach((dateStr, index) => {
|
||
|
|
if (dateStr === startDate) {
|
||
|
|
startColumn = index + 1;
|
||
|
|
}
|
||
|
|
if (dateStr === endDate) {
|
||
|
|
endColumn = index + 1;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Handle events that start before or end after the week
|
||
|
|
if (startColumn === -1) {
|
||
|
|
startColumn = 1; // Event starts before this week
|
||
|
|
}
|
||
|
|
if (endColumn === -1) {
|
||
|
|
endColumn = this.weekDates.length; // Event ends after this week
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure end column is at least start column
|
||
|
|
if (endColumn < startColumn) {
|
||
|
|
endColumn = startColumn;
|
||
|
|
}
|
||
|
|
|
||
|
|
const columnSpan = endColumn - startColumn + 1;
|
||
|
|
|
||
|
|
return { startColumn, endColumn, columnSpan };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find available row using overlap detection
|
||
|
|
*/
|
||
|
|
private findAvailableRow(
|
||
|
|
newStartColumn: number,
|
||
|
|
newEndColumn: number,
|
||
|
|
existingLayouts: Map<string, EventLayout>
|
||
|
|
): number {
|
||
|
|
const occupiedRows = new Set<number>();
|
||
|
|
|
||
|
|
// Check all existing events for overlaps
|
||
|
|
existingLayouts.forEach(layout => {
|
||
|
|
const overlaps = this.columnsOverlap(
|
||
|
|
newStartColumn, newEndColumn,
|
||
|
|
layout.startColumn, layout.endColumn
|
||
|
|
);
|
||
|
|
|
||
|
|
if (overlaps) {
|
||
|
|
occupiedRows.add(layout.row);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Find first available row
|
||
|
|
let targetRow = 1;
|
||
|
|
while (occupiedRows.has(targetRow)) {
|
||
|
|
targetRow++;
|
||
|
|
}
|
||
|
|
|
||
|
|
return targetRow;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if two column ranges overlap
|
||
|
|
*/
|
||
|
|
private columnsOverlap(
|
||
|
|
startA: number, endA: number,
|
||
|
|
startB: number, endB: number
|
||
|
|
): boolean {
|
||
|
|
// Two ranges overlap if one doesn't end before the other starts
|
||
|
|
return !(endA < startB || endB < startA);
|
||
|
|
}
|
||
|
|
}
|