Calendar/src/renderers/EventRenderer.ts

377 lines
14 KiB
TypeScript
Raw Normal View History

2025-08-07 00:15:44 +02:00
// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
2025-08-07 00:15:44 +02:00
import { CalendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
2025-08-07 00:15:44 +02:00
/**
* Interface for event rendering strategies
*/
export interface EventRendererStrategy {
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void;
clearEvents(container?: HTMLElement): void;
2025-08-07 00:15:44 +02:00
}
/**
* Base class for event renderers with common functionality
*/
export abstract class BaseEventRenderer implements EventRendererStrategy {
protected dateCalculator: DateCalculator;
constructor(config: CalendarConfig) {
this.dateCalculator = new DateCalculator(config);
}
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
2025-08-07 00:15:44 +02:00
console.log('BaseEventRenderer: renderEvents called with', events.length, 'events');
// NOTE: Removed clearEvents() to support sliding animation
// With sliding animation, multiple grid containers exist simultaneously
// clearEvents() would remove events from all containers, breaking the animation
// Events are now rendered directly into the new container without clearing
2025-08-07 00:15:44 +02:00
// Separate all-day events from regular events
const allDayEvents = events.filter(event => event.allDay);
const regularEvents = events.filter(event => !event.allDay);
console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events and ${regularEvents.length} regular events`);
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
this.renderAllDayEvents(allDayEvents, container, config);
2025-08-07 00:15:44 +02:00
// Find columns in the specific container for regular events
const columns = this.getColumns(container);
console.log(`BaseEventRenderer: Found ${columns.length} columns in container`);
2025-08-13 23:05:58 +02:00
columns.forEach(column => {
const columnEvents = this.getEventsForColumn(column, regularEvents);
console.log(`BaseEventRenderer: Rendering ${columnEvents.length} regular events in column`);
2025-08-07 00:15:44 +02:00
2025-08-13 23:05:58 +02:00
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
columnEvents.forEach(event => {
2025-08-09 01:16:04 +02:00
console.log(`BaseEventRenderer: Rendering event "${event.title}" in events layer`);
2025-08-07 00:15:44 +02:00
this.renderEvent(event, eventsLayer, config);
2025-08-13 23:05:58 +02:00
});
// Debug: Verify events were actually added
const renderedEvents = eventsLayer.querySelectorAll('swp-event');
console.log(`BaseEventRenderer: Events layer now has ${renderedEvents.length} events`);
2025-08-07 00:15:44 +02:00
} else {
2025-08-13 23:05:58 +02:00
console.warn('BaseEventRenderer: No events layer found in column');
2025-08-07 00:15:44 +02:00
}
});
}
// Abstract methods that subclasses must implement
protected abstract getColumns(container: HTMLElement): HTMLElement[];
2025-08-13 23:05:58 +02:00
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
/**
* Render all-day events in the header row 2
*/
protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
2025-08-26 00:05:42 +02:00
console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events using nested grid`);
// Find the calendar header
const calendarHeader = container.querySelector('swp-calendar-header');
if (!calendarHeader) {
console.warn('BaseEventRenderer: No calendar header found for all-day events');
return;
}
2025-08-26 00:05:42 +02:00
// Find the all-day container
const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
if (!allDayContainer) {
console.warn('BaseEventRenderer: No swp-allday-container found - HeaderRenderer should create this');
return;
}
// Clear existing events
allDayContainer.innerHTML = '';
if (allDayEvents.length === 0) {
// No events - just return
this.updateAllDayHeight(1);
return;
}
// Build date to column mapping
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const dateToColumnMap = new Map<string, number>();
dayHeaders.forEach((header, index) => {
const dateStr = (header as any).dataset.date;
if (dateStr) {
dateToColumnMap.set(dateStr, index + 1); // 1-based column index
}
});
2025-08-26 00:05:42 +02:00
// Calculate grid spans for all events
const eventSpans = allDayEvents.map(event => ({
event,
span: this.calculateEventGridSpan(event, dateToColumnMap)
})).filter(item => item.span.columnSpan > 0); // Remove events outside visible range
// Simple row assignment using overlap detection
const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = [];
2025-08-26 00:05:42 +02:00
eventSpans.forEach(eventItem => {
let assignedRow = 1;
2025-08-26 00:05:42 +02:00
// Find first row where this event doesn't overlap with any existing event
while (true) {
const rowEvents = eventPlacements.filter(item => item.row === assignedRow);
const hasOverlap = rowEvents.some(rowEvent =>
this.eventsOverlap(eventItem.span, rowEvent.span)
);
if (!hasOverlap) {
break; // Found available row
}
assignedRow++;
}
2025-08-26 00:05:42 +02:00
eventPlacements.push({
event: eventItem.event,
span: eventItem.span,
row: assignedRow
});
});
2025-08-26 00:05:42 +02:00
// Get max row needed
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
2025-08-26 00:05:42 +02:00
// Place events directly in the single container
eventPlacements.forEach(({ event, span, row }) => {
// Create the all-day event element
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.textContent = event.title;
allDayEvent.setAttribute('data-event-id', event.id);
allDayEvent.setAttribute('data-type', event.type || 'work');
// Set grid position (column and row)
(allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1
? `${span.startColumn} / span ${span.columnSpan}`
: `${span.startColumn}`;
(allDayEvent as HTMLElement).style.gridRow = row.toString();
// Use event metadata for color if available
if (event.metadata?.color) {
(allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color;
}
allDayContainer.appendChild(allDayEvent);
console.log(`BaseEventRenderer: Placed "${event.title}" in row ${row}, columns ${span.startColumn} to ${span.startColumn + span.columnSpan - 1}`);
});
2025-08-26 00:05:42 +02:00
// Update height based on max row
this.updateAllDayHeight(maxRow);
2025-08-26 00:05:42 +02:00
console.log(`BaseEventRenderer: Created ${maxRow} rows with auto-expanding grid`);
}
2025-08-07 00:15:44 +02:00
protected renderEvent(event: CalendarEvent, container: Element, config: CalendarConfig): void {
const eventElement = document.createElement('swp-event');
eventElement.dataset.eventId = event.id;
eventElement.dataset.type = event.type;
// Calculate position based on time
const position = this.calculateEventPosition(event, config);
eventElement.style.position = 'absolute';
eventElement.style.top = `${position.top + 1}px`;
eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines.
2025-08-07 00:15:44 +02:00
// Color is now handled by CSS classes based on data-type attribute
2025-08-07 00:15:44 +02:00
// Format time for display
const startTime = this.dateCalculator.formatTime(new Date(event.start));
const endTime = this.dateCalculator.formatTime(new Date(event.end));
2025-08-07 00:15:44 +02:00
// Create event content
eventElement.innerHTML = `
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
container.appendChild(eventElement);
2025-08-09 01:16:04 +02:00
console.log(`BaseEventRenderer: Created event element for "${event.title}":`, {
top: eventElement.style.top,
height: eventElement.style.height,
dataType: eventElement.dataset.type,
2025-08-09 01:16:04 +02:00
position: eventElement.style.position,
innerHTML: eventElement.innerHTML
});
2025-08-07 00:15:44 +02:00
}
protected calculateEventPosition(event: CalendarEvent, config: CalendarConfig): { top: number; height: number } {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
2025-08-09 01:16:04 +02:00
const gridSettings = config.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
2025-08-07 00:15:44 +02:00
// Calculate minutes from visible day start
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
const dayStartMinutes = dayStartHour * 60;
// Calculate top position (subtract day start to align with time axis)
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
// Calculate height
const durationMinutes = endMinutes - startMinutes;
const height = (durationMinutes / 60) * hourHeight;
2025-08-09 01:16:04 +02:00
console.log('Event positioning calculation:', {
eventTime: `${startDate.getHours()}:${startDate.getMinutes()}`,
startMinutes,
endMinutes,
dayStartMinutes,
dayStartHour,
hourHeight,
top,
height
});
2025-08-07 00:15:44 +02:00
return { top, height };
}
protected formatTime(isoString: string): string {
const date = new Date(isoString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? 'PM' : 'AM';
const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
}
2025-08-26 00:05:42 +02:00
/**
* Update all-day row height based on number of rows
*/
private updateAllDayHeight(maxRows: number): void {
const root = document.documentElement;
const eventHeight = parseInt(getComputedStyle(root).getPropertyValue('--allday-event-height') || '26');
const calculatedHeight = maxRows * eventHeight;
root.style.setProperty('--all-day-row-height', `${calculatedHeight}px`);
console.log(`BaseEventRenderer: Set all-day height to ${calculatedHeight}px for ${maxRows} rows`);
}
/**
* Calculate grid column span for event
*/
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const startDateKey = this.dateCalculator.formatISODate(startDate);
const startColumn = dateToColumnMap.get(startDateKey);
if (!startColumn) {
return { startColumn: 0, columnSpan: 0 }; // Event outside visible range
}
// Calculate span by checking each day
let endColumn = startColumn;
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
currentDate.setDate(currentDate.getDate() + 1);
const dateKey = this.dateCalculator.formatISODate(currentDate);
const col = dateToColumnMap.get(dateKey);
if (col) {
endColumn = col;
} else {
break; // Event extends beyond visible range
}
}
const columnSpan = endColumn - startColumn + 1;
return { startColumn, columnSpan };
}
/**
* Check if two events overlap in columns
*/
private eventsOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean {
const event1End = event1Span.startColumn + event1Span.columnSpan - 1;
const event2End = event2Span.startColumn + event2Span.columnSpan - 1;
return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn);
}
clearEvents(container?: HTMLElement): void {
const selector = 'swp-event';
const existingEvents = container
? container.querySelectorAll(selector)
: document.querySelectorAll(selector);
2025-08-09 01:16:04 +02:00
if (existingEvents.length > 0) {
console.log(`BaseEventRenderer: Clearing ${existingEvents.length} events`,
container ? 'from container' : 'globally');
2025-08-09 01:16:04 +02:00
}
2025-08-07 00:15:44 +02:00
existingEvents.forEach(event => event.remove());
}
}
/**
* Date-based event renderer
*/
export class DateEventRenderer extends BaseEventRenderer {
protected getColumns(container: HTMLElement): HTMLElement[] {
const columns = container.querySelectorAll('swp-day-column');
console.log('DateEventRenderer: Found', columns.length, 'day columns in container');
2025-08-13 23:05:58 +02:00
return Array.from(columns) as HTMLElement[];
}
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const columnDate = column.dataset.date;
if (!columnDate) {
console.log(`DateEventRenderer: Column has no dataset.date`);
return [];
}
2025-08-13 23:05:58 +02:00
const columnEvents = events.filter(event => {
const eventDate = new Date(event.start);
const eventDateStr = this.dateCalculator.formatISODate(eventDate);
const matches = eventDateStr === columnDate;
if (!matches) {
if(event.title == 'Architecture Planning')
console.log(`DateEventRenderer: Event ${event.title} (${eventDateStr}) does not match column (${columnDate})`);
}
return matches;
2025-08-13 23:05:58 +02:00
});
console.log(`DateEventRenderer: Column ${columnDate} has ${columnEvents.length} events from ${events.length} total`);
2025-08-13 23:05:58 +02:00
return columnEvents;
}
2025-08-07 00:15:44 +02:00
}
/**
* Resource-based event renderer
*/
export class ResourceEventRenderer extends BaseEventRenderer {
protected getColumns(container: HTMLElement): HTMLElement[] {
const columns = container.querySelectorAll('swp-resource-column');
console.log('ResourceEventRenderer: Found', columns.length, 'resource columns in container');
2025-08-13 23:05:58 +02:00
return Array.from(columns) as HTMLElement[];
}
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const resourceName = column.dataset.resource;
if (!resourceName) return [];
const columnEvents = events.filter(event => {
return event.resource?.name === resourceName;
});
console.log(`ResourceEventRenderer: Resource ${resourceName} has ${columnEvents.length} events`);
return columnEvents;
}
2025-08-07 00:15:44 +02:00
}