Enhances event layout engine with advanced rendering logic

Introduces sophisticated event layout algorithm for handling complex scheduling scenarios

Adds support for:
- Grid and stacked event rendering
- Automatic column allocation
- Nested event stacking
- Threshold-based event grouping

Improves visual representation of overlapping and concurrent events
This commit is contained in:
Janus C. H. Knudsen 2025-12-11 18:11:11 +01:00
parent 4e22fbc948
commit 70172e8f10
26 changed files with 2108 additions and 44 deletions

View file

@ -1,10 +1,12 @@
import { ICalendarEvent, IEventBus } from '../../types/CalendarTypes';
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
import { CoreEvents } from '../../constants/CoreEvents';
import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragTypes';
import { calculateColumnLayout } from './EventLayoutEngine';
import { IGridGroupLayout } from './EventLayoutTypes';
/**
* EventRenderer - Renders calendar events to the DOM
@ -15,19 +17,21 @@ import { IDragColumnChangePayload, IDragMovePayload } from '../../types/DragType
* - Event data retrieved via EventService when needed
*/
export class EventRenderer {
private container: HTMLElement | null = null;
constructor(
private eventService: EventService,
private dateService: DateService,
private gridConfig: IGridConfig,
private eventBus: IEventBus
) {
this.setupDragListeners();
this.setupListeners();
}
/**
* Setup listeners for drag-drop events
* Setup listeners for drag-drop and update events
*/
private setupDragListeners(): void {
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
this.handleColumnChange(payload);
@ -37,6 +41,95 @@ export class EventRenderer {
const payload = (e as CustomEvent<IDragMovePayload>).detail;
this.updateDragTimestamp(payload);
});
this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => {
const payload = (e as CustomEvent<IEventUpdatedPayload>).detail;
this.handleEventUpdated(payload);
});
}
/**
* Handle EVENT_UPDATED - re-render affected columns
*/
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
// Re-render source column (if different from target)
if (payload.sourceDateKey !== payload.targetDateKey ||
payload.sourceResourceId !== payload.targetResourceId) {
await this.rerenderColumn(payload.sourceDateKey, payload.sourceResourceId);
}
// Re-render target column
await this.rerenderColumn(payload.targetDateKey, payload.targetResourceId);
}
/**
* Re-render a single column with fresh data from IndexedDB
*/
private async rerenderColumn(dateKey: string, resourceId?: string): Promise<void> {
const column = this.findColumn(dateKey, resourceId);
if (!column) return;
// Get date range for this day
const startDate = new Date(dateKey);
const endDate = new Date(dateKey);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = resourceId
? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate)
: await this.eventService.getByDateRange(startDate, endDate);
// Filter to timed events and match dateKey exactly
const timedEvents = events.filter(event =>
!event.allDay && this.dateService.getDateKey(event.start) === dateKey
);
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
}
/**
* Find a column element by dateKey and optional resourceId
*/
private findColumn(dateKey: string, resourceId?: string): HTMLElement | null {
if (!this.container) return null;
const columns = this.container.querySelectorAll('swp-day-column');
for (const col of columns) {
const colEl = col as HTMLElement;
if (colEl.dataset.date !== dateKey) continue;
// If resourceId specified, must match
if (resourceId && colEl.dataset.resourceId !== resourceId) continue;
// If no resourceId specified but column has one, skip (simple view case)
if (!resourceId && colEl.dataset.resourceId) continue;
return colEl;
}
return null;
}
/**
@ -93,6 +186,9 @@ export class EventRenderer {
* @param filter - Filter with 'date' and optionally 'resource' arrays
*/
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
// Store container reference for later re-renders
this.container = container;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
@ -142,12 +238,22 @@ export class EventRenderer {
// Clear existing events
eventsLayer.innerHTML = '';
// Render each timed event
columnEvents.forEach(event => {
if (!event.allDay) {
const eventElement = this.createEventElement(event);
eventsLayer!.appendChild(eventElement);
}
// Filter to timed events only
const timedEvents = columnEvents.filter(event => !event.allDay);
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups (simultaneous events side-by-side)
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events (overlapping with margin offset)
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
});
}
@ -162,8 +268,8 @@ export class EventRenderer {
private createEventElement(event: ICalendarEvent): HTMLElement {
const element = document.createElement('swp-event');
// Only essential data attribute
element.dataset.id = event.id;
// Only essential data attribute (eventId for DragDropManager compatibility)
element.dataset.eventId = event.id;
// Calculate position
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
@ -187,9 +293,15 @@ export class EventRenderer {
}
/**
* Get color class based on event type
* Get color class based on metadata.color or event type
*/
private getColorClass(event: ICalendarEvent): string {
// Check metadata.color first
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
// Fallback to type-based color
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
@ -208,4 +320,70 @@ export class EventRenderer {
div.textContent = text;
return div.innerHTML;
}
/**
* Render a GRID group with side-by-side columns
* Used when multiple events start at the same time
*/
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
const group = document.createElement('swp-event-group');
group.classList.add(`cols-${layout.columns.length}`);
group.style.top = `${layout.position.top}px`;
// Stack level styling for entire group (if nested in another event)
if (layout.stackLevel > 0) {
group.style.marginLeft = `${layout.stackLevel * 15}px`;
group.style.zIndex = `${100 + layout.stackLevel}`;
}
// Calculate the height needed for the group (tallest event)
let maxBottom = 0;
for (const event of layout.events) {
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
const eventBottom = pos.top + pos.height;
if (eventBottom > maxBottom) maxBottom = eventBottom;
}
const groupHeight = maxBottom - layout.position.top;
group.style.height = `${groupHeight}px`;
// Create wrapper div for each column
layout.columns.forEach(columnEvents => {
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
columnEvents.forEach(event => {
const eventEl = this.createEventElement(event);
// Position relative to group top
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
eventEl.style.top = `${pos.top - layout.position.top}px`;
eventEl.style.position = 'absolute';
eventEl.style.left = '0';
eventEl.style.right = '0';
wrapper.appendChild(eventEl);
});
group.appendChild(wrapper);
});
return group;
}
/**
* Render a STACKED event with margin-left offset
* Used for overlapping events that don't start at the same time
*/
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
const element = this.createEventElement(event);
// Add stack metadata for drag-drop and other features
element.dataset.stackLink = JSON.stringify({ stackLevel });
// Visual styling based on stack level
if (stackLevel > 0) {
element.style.marginLeft = `${stackLevel * 15}px`;
element.style.zIndex = `${100 + stackLevel}`;
}
return element;
}
}