Improves all-day event layout calculation
Refactors all-day event rendering to use a layout engine for overlap detection and positioning, ensuring events are placed in available rows and columns. Removes deprecated method and adds unit tests.
This commit is contained in:
parent
274753936e
commit
a624394ffb
11 changed files with 2898 additions and 145 deletions
166
src/utils/AllDayLayoutEngine.ts
Normal file
166
src/utils/AllDayLayoutEngine.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue