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

BIN
.workbench/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

15
.workbench/scenarie3.html Normal file
View file

@ -0,0 +1,15 @@
<swp-day-column data-date="2025-10-07" style="--before-work-height: 240px; --after-work-top: 880px;"><swp-events-layer><swp-event-group class="cols-2 stack-level-3" data-stack-link="{&quot;stackLevel&quot;:3}" style="top: 321px; margin-left: 45px; z-index: 103;"><div style="position: relative;"><swp-event data-event-id="S3B" data-title="Scenario 3: Event B" data-start="2025-10-07T08:00:00.000Z" data-end="2025-10-07T11:00:00.000Z" data-type="work" data-duration="180" style="position: absolute; top: 0px; height: 237px; left: 0px; right: 0px;">
<swp-event-time data-duration="180">10:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 3: Event B</swp-event-title>
</swp-event></div><div style="position: relative;"><swp-event data-event-id="S3D" data-title="Scenario 3: Event D" data-start="2025-10-07T10:30:00.000Z" data-end="2025-10-07T11:30:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 200px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:30 - 13:30</swp-event-time>
<swp-event-title>Scenario 3: Event D</swp-event-title>
</swp-event></div></swp-event-group><swp-event data-event-id="S3A" data-title="Scenario 3: Event A" data-start="2025-10-07T07:00:00.000Z" data-end="2025-10-07T13:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0,&quot;next&quot;:&quot;S3B&quot;}" style="position: absolute; top: 241px; height: 477px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">09:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 3: Event A</swp-event-title>
</swp-event><swp-event data-event-id="S3C" data-title="Scenario 3: Event C" data-start="2025-10-07T09:00:00.000Z" data-end="2025-10-07T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2,&quot;prev&quot;:&quot;S3B&quot;}" style="position: absolute; top: 401px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 3: Event C</swp-event-title>
</swp-event><swp-event data-event-id="S4A" data-title="Scenario 4: Event A" data-start="2025-10-07T14:00:00.000Z" data-end="2025-10-07T20:00:00.000Z" data-type="meeting" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0,&quot;next&quot;:&quot;S4B&quot;}" style="position: absolute; top: 801px; height: 477px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">16:00 - 22:00</swp-event-time>
</swp-event></swp-events-layer></swp-day-column>

View file

@ -2246,5 +2246,434 @@
"duration": 240, "duration": 240,
"color": "#2196f3" "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 { CalendarEvent } from '../types/CalendarTypes';
import { EventStackManager, EventGroup, StackLink } from './EventStackManager'; import { EventStackManager, EventGroup, StackLink } from './EventStackManager';
import { PositionUtils } from '../utils/PositionUtils'; import { PositionUtils } from '../utils/PositionUtils';
import { calendarConfig } from '../core/CalendarConfig';
export interface GridGroupLayout { export interface GridGroupLayout {
events: CalendarEvent[]; events: CalendarEvent[];
@ -35,57 +36,92 @@ 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 { public calculateColumnLayout(columnEvents: CalendarEvent[]): ColumnLayout {
if (columnEvents.length === 0) { if (columnEvents.length === 0) {
return { gridGroups: [], stackedEvents: [] }; 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 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 => { // Process events recursively
const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks); while (remaining.length > 0) {
const earliestEvent = group.events[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
};
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 position = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
const columns = this.allocateColumns(group.events); const columns = this.allocateColumns(gridCandidates);
gridGroupLayouts.push({ gridGroupLayouts.push({
events: group.events, events: gridCandidates,
stackLevel: gridStackLevel, stackLevel: gridStackLevel,
position: { top: position.top + 1 }, position: { top: position.top + 1 },
columns columns
}); });
group.events.forEach(e => renderedEventIds.add(e.id)); // Mark all events in grid with their stack level
}); gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel }));
// Step 4: Build stacked event layouts for remaining events // Remove all events in this grid from remaining
const remainingEvents = columnEvents.filter(e => !renderedEventIds.has(e.id)); remaining = remaining.filter(e => !gridCandidates.includes(e));
const stackedEventLayouts: StackedEventLayout[] = remainingEvents.map(event => { } else {
const stackLink = allStackLinks.get(event.id)!; // Render first event as STACKED
const position = PositionUtils.calculateEventPosition(event.start, event.end); const stackLevel = this.calculateStackLevelFromRendered(
firstEvent,
renderedEventsWithLevels
);
return { const position = PositionUtils.calculateEventPosition(firstEvent.start, firstEvent.end);
event, stackedEventLayouts.push({
stackLink, event: firstEvent,
stackLink: { stackLevel },
position: { top: position.top + 1, height: position.height - 3 } 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 { return {
gridGroups: gridGroupLayouts, gridGroups: gridGroupLayouts,
stackedEvents: stackedEventLayouts stackedEvents: stackedEventLayouts
@ -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( private calculateGridGroupStackLevelFromRendered(
group: EventGroup, gridEvents: CalendarEvent[],
allEvents: CalendarEvent[], renderedEventsWithLevels: Array<{ event: CalendarEvent; level: number }>
stackLinks: Map<string, StackLink>
): number { ): number {
const groupEventIds = new Set(group.events.map(e => e.id)); // Find highest stack level of any rendered event that overlaps with this grid
// 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
let maxOverlappingLevel = -1; let maxOverlappingLevel = -1;
for (const gridEvent of group.events) { for (const gridEvent of gridEvents) {
for (const outsideEvent of outsideEvents) { for (const rendered of renderedEventsWithLevels) {
if (this.stackManager.doEventsOverlap(gridEvent, outsideEvent)) { if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) {
const outsideLink = stackLinks.get(outsideEvent.id); maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
if (outsideLink) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, outsideLink.stackLevel);
}
} }
} }
} }
// 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; return maxOverlappingLevel + 1;
} }