Implements event layout coordinator

Introduces a coordinator to manage event layout calculations,
separating this logic from rendering.
It calculates stack levels and allocates event columns for
improved visual organization of calendar events.
This commit is contained in:
Janus C. H. Knudsen 2025-10-06 17:53:25 +02:00
parent 6b8c5d4673
commit 06356df2a3
4 changed files with 545 additions and 56 deletions

View file

@ -2246,5 +2246,434 @@
"duration": 240,
"color": "#2196f3"
}
},
{
"id": "S1A",
"title": "Scenario 1: Event A",
"start": "2025-10-06T05:00:00Z",
"end": "2025-10-06T10:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 300,
"color": "#ff6b6b"
}
},
{
"id": "S1B",
"title": "Scenario 1: Event B",
"start": "2025-10-06T06:00:00Z",
"end": "2025-10-06T08:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 120,
"color": "#4ecdc4"
}
},
{
"id": "S1C",
"title": "Scenario 1: Event C",
"start": "2025-10-06T08:30:00Z",
"end": "2025-10-06T09:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 30,
"color": "#ffe66d"
}
},
{
"id": "S2A",
"title": "Scenario 2: Event A",
"start": "2025-10-06T11:00:00Z",
"end": "2025-10-06T17:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 360,
"color": "#ff6b6b"
}
},
{
"id": "S2B",
"title": "Scenario 2: Event B",
"start": "2025-10-06T12:00:00Z",
"end": "2025-10-06T13:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#4ecdc4"
}
},
{
"id": "S2C",
"title": "Scenario 2: Event C",
"start": "2025-10-06T13:30:00Z",
"end": "2025-10-06T14:30:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#ffe66d"
}
},
{
"id": "S2D",
"title": "Scenario 2: Event D",
"start": "2025-10-06T15:00:00Z",
"end": "2025-10-06T16:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#a8e6cf"
}
},
{
"id": "S3A",
"title": "Scenario 3: Event A",
"start": "2025-10-07T07:00:00Z",
"end": "2025-10-07T13:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 360,
"color": "#ff6b6b"
}
},
{
"id": "S3B",
"title": "Scenario 3: Event B",
"start": "2025-10-07T08:00:00Z",
"end": "2025-10-07T11:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 180,
"color": "#4ecdc4"
}
},
{
"id": "S3C",
"title": "Scenario 3: Event C",
"start": "2025-10-07T09:00:00Z",
"end": "2025-10-07T10:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#ffe66d"
}
},
{
"id": "S3D",
"title": "Scenario 3: Event D",
"start": "2025-10-07T10:30:00Z",
"end": "2025-10-07T11:30:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#a8e6cf"
}
},
{
"id": "S4A",
"title": "Scenario 4: Event A",
"start": "2025-10-07T14:00:00Z",
"end": "2025-10-07T20:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 360,
"color": "#ff6b6b"
}
},
{
"id": "S4B",
"title": "Scenario 4: Event B",
"start": "2025-10-07T15:00:00Z",
"end": "2025-10-07T19:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 240,
"color": "#4ecdc4"
}
},
{
"id": "S4C",
"title": "Scenario 4: Event C",
"start": "2025-10-07T16:00:00Z",
"end": "2025-10-07T18:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 120,
"color": "#ffe66d"
}
},
{
"id": "S5A",
"title": "Scenario 5: Event A",
"start": "2025-10-08T05:00:00Z",
"end": "2025-10-08T08:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 180,
"color": "#ff6b6b"
}
},
{
"id": "S5B",
"title": "Scenario 5: Event B",
"start": "2025-10-08T06:00:00Z",
"end": "2025-10-08T07:30:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 90,
"color": "#4ecdc4"
}
},
{
"id": "S5C",
"title": "Scenario 5: Event C",
"start": "2025-10-08T06:00:00Z",
"end": "2025-10-08T07:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#ffe66d"
}
},
{
"id": "S6A",
"title": "Scenario 6: Event A",
"start": "2025-10-08T09:00:00Z",
"end": "2025-10-08T12:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 180,
"color": "#ff6b6b"
}
},
{
"id": "S6B",
"title": "Scenario 6: Event B",
"start": "2025-10-08T10:00:00Z",
"end": "2025-10-08T11:30:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 90,
"color": "#4ecdc4"
}
},
{
"id": "S6C",
"title": "Scenario 6: Event C",
"start": "2025-10-08T10:00:00Z",
"end": "2025-10-08T11:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#ffe66d"
}
},
{
"id": "S6D",
"title": "Scenario 6: Event D",
"start": "2025-10-08T10:30:00Z",
"end": "2025-10-08T10:45:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 15,
"color": "#a8e6cf"
}
},
{
"id": "S7A",
"title": "Scenario 7: Event A",
"start": "2025-10-09T05:00:00Z",
"end": "2025-10-09T07:30:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 150,
"color": "#009688"
}
},
{
"id": "S7B",
"title": "Scenario 7: Event B",
"start": "2025-10-09T05:00:00Z",
"end": "2025-10-09T07:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 120,
"color": "#ff5722"
}
},
{
"id": "S8A",
"title": "Scenario 8: Event A",
"start": "2025-10-09T08:00:00Z",
"end": "2025-10-09T09:00:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#ff6b6b"
}
},
{
"id": "S8B",
"title": "Scenario 8: Event B",
"start": "2025-10-09T08:15:00Z",
"end": "2025-10-09T09:30:00Z",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 75,
"color": "#4ecdc4"
}
},
{
"id": "S9A",
"title": "Scenario 9: Event A",
"start": "2025-10-09T10:00:00Z",
"end": "2025-10-09T11:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#ff6b6b"
}
},
{
"id": "S9B",
"title": "Scenario 9: Event B",
"start": "2025-10-09T10:30:00Z",
"end": "2025-10-09T11:30:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#4ecdc4"
}
},
{
"id": "S9C",
"title": "Scenario 9: Event C",
"start": "2025-10-09T11:15:00Z",
"end": "2025-10-09T13:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 105,
"color": "#ffe66d"
}
},
{
"id": "S10A",
"title": "Scenario 10: Event A",
"start": "2025-10-10T10:00:00Z",
"end": "2025-10-10T13:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 180,
"color": "#ff6b6b"
}
},
{
"id": "S10B",
"title": "Scenario 10: Event B",
"start": "2025-10-10T10:30:00Z",
"end": "2025-10-10T11:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 30,
"color": "#4ecdc4"
}
},
{
"id": "S10C",
"title": "Scenario 10: Event C",
"start": "2025-10-10T11:30:00Z",
"end": "2025-10-10T12:30:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#ffe66d"
}
},
{
"id": "S10D",
"title": "Scenario 10: Event D",
"start": "2025-10-10T12:00:00Z",
"end": "2025-10-10T13:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#a8e6cf"
}
},
{
"id": "S10E",
"title": "Scenario 10: Event E",
"start": "2025-10-10T12:00:00Z",
"end": "2025-10-10T13:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": {
"duration": 60,
"color": "#dda15e"
}
}
]

View file

@ -8,6 +8,7 @@
import { CalendarEvent } from '../types/CalendarTypes';
import { EventStackManager, EventGroup, StackLink } from './EventStackManager';
import { PositionUtils } from '../utils/PositionUtils';
import { calendarConfig } from '../core/CalendarConfig';
export interface GridGroupLayout {
events: CalendarEvent[];
@ -35,56 +36,91 @@ export class EventLayoutCoordinator {
}
/**
* Calculate complete layout for a column of events
* Calculate complete layout for a column of events (recursive approach)
*/
public calculateColumnLayout(columnEvents: CalendarEvent[]): ColumnLayout {
if (columnEvents.length === 0) {
return { gridGroups: [], stackedEvents: [] };
}
// Step 1: Calculate stack levels for ALL events first (to understand overlaps)
const allStackLinks = this.stackManager.createOptimizedStackLinks(columnEvents);
// Step 2: Find grid candidates (start together ±15 min)
const groups = this.stackManager.groupEventsByStartTime(columnEvents);
const gridGroups = groups.filter(group => {
if (group.events.length <= 1) return false;
group.containerType = this.stackManager.decideContainerType(group);
return group.containerType === 'GRID';
});
// Step 3: Build grid group layouts
const gridGroupLayouts: GridGroupLayout[] = [];
const renderedEventIds = new Set<string>();
const stackedEventLayouts: StackedEventLayout[] = [];
const renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }> = [];
let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
gridGroups.forEach(group => {
const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks);
const earliestEvent = group.events[0];
const position = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
const columns = this.allocateColumns(group.events);
// Process events recursively
while (remaining.length > 0) {
// Take first event
const firstEvent = remaining[0];
gridGroupLayouts.push({
events: group.events,
stackLevel: gridStackLevel,
position: { top: position.top + 1 },
columns
});
// Find events that could be in GRID with first event
// (start within threshold AND overlap)
const gridSettings = calendarConfig.getGridSettings();
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
group.events.forEach(e => renderedEventIds.add(e.id));
});
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);
// Step 4: Build stacked event layouts for remaining events
const remainingEvents = columnEvents.filter(e => !renderedEventIds.has(e.id));
const stackedEventLayouts: StackedEventLayout[] = remainingEvents.map(event => {
const stackLink = allStackLinks.get(event.id)!;
const position = PositionUtils.calculateEventPosition(event.start, event.end);
// Only add if starts within threshold AND overlaps with firstEvent
if (startDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(firstEvent, candidate)) {
gridCandidates.push(candidate);
}
}
return {
event,
stackLink,
position: { top: position.top + 1, height: position.height - 3 }
// Decide: should this group be GRID or STACK?
const group: EventGroup = {
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
);
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);
}
}
return {
gridGroups: gridGroupLayouts,
@ -93,33 +129,42 @@ export class EventLayoutCoordinator {
}
/**
* Calculate stack level for a grid group based on what it overlaps OUTSIDE the group
* Calculate stack level for a grid group based on already rendered events
*/
private calculateGridGroupStackLevel(
group: EventGroup,
allEvents: CalendarEvent[],
stackLinks: Map<string, StackLink>
private calculateGridGroupStackLevelFromRendered(
gridEvents: CalendarEvent[],
renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }>
): number {
const groupEventIds = new Set(group.events.map(e => e.id));
// Find all events OUTSIDE this group
const outsideEvents = allEvents.filter(e => !groupEventIds.has(e.id));
// Find the highest stackLevel of any event that overlaps with ANY event in the grid group
// Find highest stack level of any rendered event that overlaps with this grid
let maxOverlappingLevel = -1;
for (const gridEvent of group.events) {
for (const outsideEvent of outsideEvents) {
if (this.stackManager.doEventsOverlap(gridEvent, outsideEvent)) {
const outsideLink = stackLinks.get(outsideEvent.id);
if (outsideLink) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, outsideLink.stackLevel);
}
for (const gridEvent of gridEvents) {
for (const rendered of renderedEventsWithLevels) {
if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
}
}
}
// Grid group should be one level above the highest overlapping event
return maxOverlappingLevel + 1;
}
/**
* 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
let maxOverlappingLevel = -1;
for (const rendered of renderedEventsWithLevels) {
if (this.stackManager.doEventsOverlap(event, rendered.event)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
}
}
return maxOverlappingLevel + 1;
}