Ensures the all-day event container's grid layout is correctly updated to reflect the number of rows needed, even when the overall height doesn't change. This prevents layout issues when events are rearranged without triggering a height recalculation. Also updates the grid template when the height is updated in the BaseEventRenderer.
384 lines
No EOL
14 KiB
TypeScript
384 lines
No EOL
14 KiB
TypeScript
// Event rendering strategy interface and implementations
|
|
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
|
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
|
|
import { CalendarConfig } from '../core/CalendarConfig';
|
|
import { DateCalculator } from '../utils/DateCalculator';
|
|
|
|
/**
|
|
* Interface for event rendering strategies
|
|
*/
|
|
export interface EventRendererStrategy {
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void;
|
|
clearEvents(container?: HTMLElement): void;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
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
|
|
|
|
// 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);
|
|
|
|
// Find columns in the specific container for regular events
|
|
const columns = this.getColumns(container);
|
|
console.log(`BaseEventRenderer: Found ${columns.length} columns in container`);
|
|
|
|
columns.forEach(column => {
|
|
const columnEvents = this.getEventsForColumn(column, regularEvents);
|
|
console.log(`BaseEventRenderer: Rendering ${columnEvents.length} regular events in column`);
|
|
|
|
const eventsLayer = column.querySelector('swp-events-layer');
|
|
if (eventsLayer) {
|
|
columnEvents.forEach(event => {
|
|
console.log(`BaseEventRenderer: Rendering event "${event.title}" in events layer`);
|
|
this.renderEvent(event, eventsLayer, config);
|
|
});
|
|
|
|
// Debug: Verify events were actually added
|
|
const renderedEvents = eventsLayer.querySelectorAll('swp-event');
|
|
console.log(`BaseEventRenderer: Events layer now has ${renderedEvents.length} events`);
|
|
} else {
|
|
console.warn('BaseEventRenderer: No events layer found in column');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Abstract methods that subclasses must implement
|
|
protected abstract getColumns(container: HTMLElement): HTMLElement[];
|
|
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 {
|
|
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;
|
|
}
|
|
|
|
// 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
|
|
}
|
|
});
|
|
|
|
// 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 }> = [];
|
|
|
|
eventSpans.forEach(eventItem => {
|
|
let assignedRow = 1;
|
|
|
|
// 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++;
|
|
}
|
|
|
|
eventPlacements.push({
|
|
event: eventItem.event,
|
|
span: eventItem.span,
|
|
row: assignedRow
|
|
});
|
|
});
|
|
|
|
// Get max row needed
|
|
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
|
|
|
|
// 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}`);
|
|
});
|
|
|
|
// Update height based on max row
|
|
this.updateAllDayHeight(maxRow);
|
|
|
|
console.log(`BaseEventRenderer: Created ${maxRow} rows with auto-expanding grid`);
|
|
}
|
|
|
|
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.
|
|
|
|
// Color is now handled by CSS classes based on data-type attribute
|
|
|
|
// Format time for display
|
|
const startTime = this.dateCalculator.formatTime(new Date(event.start));
|
|
const endTime = this.dateCalculator.formatTime(new Date(event.end));
|
|
|
|
// Create event content
|
|
eventElement.innerHTML = `
|
|
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
|
|
<swp-event-title>${event.title}</swp-event-title>
|
|
`;
|
|
|
|
container.appendChild(eventElement);
|
|
|
|
console.log(`BaseEventRenderer: Created event element for "${event.title}":`, {
|
|
top: eventElement.style.top,
|
|
height: eventElement.style.height,
|
|
dataType: eventElement.dataset.type,
|
|
position: eventElement.style.position,
|
|
innerHTML: eventElement.innerHTML
|
|
});
|
|
}
|
|
|
|
protected calculateEventPosition(event: CalendarEvent, config: CalendarConfig): { top: number; height: number } {
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
|
|
const gridSettings = config.getGridSettings();
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
const hourHeight = gridSettings.hourHeight;
|
|
|
|
// 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;
|
|
|
|
console.log('Event positioning calculation:', {
|
|
eventTime: `${startDate.getHours()}:${startDate.getMinutes()}`,
|
|
startMinutes,
|
|
endMinutes,
|
|
dayStartMinutes,
|
|
dayStartHour,
|
|
hourHeight,
|
|
top,
|
|
height
|
|
});
|
|
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* Update all-day row height and grid template 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`);
|
|
|
|
// Update grid-template-rows for all swp-allday-containers
|
|
const allDayContainers = document.querySelectorAll('swp-allday-container');
|
|
allDayContainers.forEach(container => {
|
|
const gridRows = `repeat(${maxRows}, var(--allday-event-height, 26px))`;
|
|
(container as HTMLElement).style.gridTemplateRows = gridRows;
|
|
});
|
|
|
|
console.log(`BaseEventRenderer: Set all-day height to ${calculatedHeight}px and grid-template-rows to ${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);
|
|
|
|
if (existingEvents.length > 0) {
|
|
console.log(`BaseEventRenderer: Clearing ${existingEvents.length} events`,
|
|
container ? 'from container' : 'globally');
|
|
}
|
|
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');
|
|
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 [];
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
console.log(`DateEventRenderer: Column ${columnDate} has ${columnEvents.length} events from ${events.length} total`);
|
|
return columnEvents;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
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;
|
|
}
|
|
} |