// Grid structure management import { eventBus } from '../core/EventBus'; import { calendarConfig } from '../core/CalendarConfig'; import { EventTypes } from '../constants/EventTypes'; import { DateUtils } from '../utils/DateUtils'; /** * Grid position interface */ interface GridPosition { minutes: number; time: string; y: number; } /** * Manages the calendar grid structure */ export class GridManager { private container: HTMLElement | null = null; private timeAxis: HTMLElement | null = null; private weekHeader: HTMLElement | null = null; private timeGrid: HTMLElement | null = null; private dayColumns: HTMLElement | null = null; private scrollableContent: HTMLElement | null = null; private currentWeek: Date | null = null; constructor() { this.init(); } private init(): void { this.findElements(); this.subscribeToEvents(); this.setupScrollSync(); } private findElements(): void { this.container = document.querySelector('swp-calendar-container'); this.timeAxis = document.querySelector('swp-time-axis'); this.weekHeader = document.querySelector('swp-week-header'); this.timeGrid = document.querySelector('swp-time-grid'); this.scrollableContent = document.querySelector('swp-scrollable-content'); } private subscribeToEvents(): void { // Re-render grid on config changes eventBus.on(EventTypes.CONFIG_UPDATE, (e: Event) => { const detail = (e as CustomEvent).detail; if (['dayStartHour', 'dayEndHour', 'hourHeight', 'view', 'weekDays'].includes(detail.key)) { this.render(); } }); // Re-render on view change eventBus.on(EventTypes.VIEW_CHANGE, () => { this.render(); }); // Re-render on period change eventBus.on(EventTypes.PERIOD_CHANGE, (e: Event) => { const detail = (e as CustomEvent).detail; this.currentWeek = detail.week; this.renderHeaders(); }); // Handle week changes from NavigationManager eventBus.on(EventTypes.WEEK_CHANGED, (e: Event) => { const detail = (e as CustomEvent).detail; this.currentWeek = detail.weekStart; this.render(); }); // Handle new week container creation eventBus.on(EventTypes.WEEK_CONTAINER_CREATED, (e: Event) => { const detail = (e as CustomEvent).detail; this.renderGridForContainer(detail.container, detail.weekStart); }); // Handle grid clicks this.setupGridInteractions(); } /** * Render the complete grid structure */ render(): void { this.renderTimeAxis(); this.renderHeaders(); this.renderGrid(); this.renderGridLines(); // Emit grid rendered event eventBus.emit(EventTypes.GRID_RENDERED); } /** * Render time axis (left side hours) */ private renderTimeAxis(): void { if (!this.timeAxis) return; const startHour = calendarConfig.get('dayStartHour'); const endHour = calendarConfig.get('dayEndHour'); this.timeAxis.innerHTML = ''; for (let hour = startHour; hour <= endHour; hour++) { const marker = document.createElement('swp-hour-marker'); marker.textContent = this.formatHour(hour); (marker as any).dataset.hour = hour; this.timeAxis.appendChild(marker); } } /** * Render week headers */ private renderHeaders(): void { if (!this.weekHeader || !this.currentWeek) return; const view = calendarConfig.get('view'); const weekDays = calendarConfig.get('weekDays'); this.weekHeader.innerHTML = ''; if (view === 'week') { const dates = this.getWeekDates(this.currentWeek); const daysToShow = dates.slice(0, weekDays); daysToShow.forEach((date, index) => { const header = document.createElement('swp-day-header'); header.innerHTML = ` ${this.getDayName(date)} ${date.getDate()} `; (header as any).dataset.date = this.formatDate(date); (header as any).dataset.dayIndex = index; // Mark today if (this.isToday(date)) { (header as any).dataset.today = 'true'; } this.weekHeader!.appendChild(header); }); } } /** * Render the main grid structure */ private renderGrid(): void { if (!this.timeGrid) return; // Clear existing columns let dayColumns = this.timeGrid.querySelector('swp-day-columns'); if (!dayColumns) { dayColumns = document.createElement('swp-day-columns'); this.timeGrid.appendChild(dayColumns); } dayColumns.innerHTML = ''; const view = calendarConfig.get('view'); const columnsCount = view === 'week' ? calendarConfig.get('weekDays') : 1; // Create columns for (let i = 0; i < columnsCount; i++) { const column = document.createElement('swp-day-column'); (column as any).dataset.columnIndex = i; if (this.currentWeek) { const dates = this.getWeekDates(this.currentWeek); if (dates[i]) { (column as any).dataset.date = this.formatDate(dates[i]); } } // Add events container const eventsLayer = document.createElement('swp-events-layer'); column.appendChild(eventsLayer); dayColumns.appendChild(column); } this.dayColumns = dayColumns as HTMLElement; this.updateGridStyles(); } /** * Render grid lines */ private renderGridLines(): void { if (!this.timeGrid) return; let gridLines = this.timeGrid.querySelector('swp-grid-lines'); if (!gridLines) { gridLines = document.createElement('swp-grid-lines'); this.timeGrid.insertBefore(gridLines, this.timeGrid.firstChild); } const totalHours = calendarConfig.totalHours; const hourHeight = calendarConfig.get('hourHeight'); // Set CSS variables this.timeGrid.style.setProperty('--total-hours', totalHours.toString()); this.timeGrid.style.setProperty('--hour-height', `${hourHeight}px`); // Grid lines are handled by CSS } /** * Update grid CSS variables */ private updateGridStyles(): void { const root = document.documentElement; const config = calendarConfig.getAll(); // Set CSS variables root.style.setProperty('--hour-height', `${config.hourHeight}px`); root.style.setProperty('--minute-height', `${config.hourHeight / 60}px`); root.style.setProperty('--snap-interval', config.snapInterval.toString()); root.style.setProperty('--day-start-hour', config.dayStartHour.toString()); root.style.setProperty('--day-end-hour', config.dayEndHour.toString()); root.style.setProperty('--work-start-hour', config.workStartHour.toString()); root.style.setProperty('--work-end-hour', config.workEndHour.toString()); // Set grid height const totalHeight = calendarConfig.totalHours * config.hourHeight; if (this.timeGrid) { this.timeGrid.style.height = `${totalHeight}px`; } } /** * Setup grid interaction handlers */ private setupGridInteractions(): void { if (!this.timeGrid) return; // Click handler this.timeGrid.addEventListener('click', (e: MouseEvent) => { // Ignore if clicking on an event if ((e.target as Element).closest('swp-event')) return; const column = (e.target as Element).closest('swp-day-column') as HTMLElement; if (!column) return; const position = this.getClickPosition(e, column); eventBus.emit(EventTypes.GRID_CLICK, { date: (column as any).dataset.date, time: position.time, minutes: position.minutes, columnIndex: parseInt((column as any).dataset.columnIndex) }); }); // Double click handler this.timeGrid.addEventListener('dblclick', (e: MouseEvent) => { // Ignore if clicking on an event if ((e.target as Element).closest('swp-event')) return; const column = (e.target as Element).closest('swp-day-column') as HTMLElement; if (!column) return; const position = this.getClickPosition(e, column); eventBus.emit(EventTypes.GRID_DBLCLICK, { date: (column as any).dataset.date, time: position.time, minutes: position.minutes, columnIndex: parseInt((column as any).dataset.columnIndex) }); }); } /** * Get click position in grid */ private getClickPosition(event: MouseEvent, column: HTMLElement): GridPosition { const rect = column.getBoundingClientRect(); const y = event.clientY - rect.top + (this.scrollableContent?.scrollTop || 0); const minuteHeight = calendarConfig.minuteHeight; const snapInterval = calendarConfig.get('snapInterval'); const dayStartHour = calendarConfig.get('dayStartHour'); // Calculate minutes from start of day let minutes = Math.floor(y / minuteHeight); // Snap to interval minutes = Math.round(minutes / snapInterval) * snapInterval; // Add day start offset const totalMinutes = (dayStartHour * 60) + minutes; return { minutes: totalMinutes, time: this.minutesToTime(totalMinutes), y: minutes * minuteHeight }; } /** * Utility methods */ private formatHour(hour: number): string { const period = hour >= 12 ? 'PM' : 'AM'; const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour); return `${displayHour} ${period}`; } private formatDate(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } private getDayName(date: Date): string { const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; return days[date.getDay()]; } private getWeekDates(weekStart: Date): Date[] { const dates: Date[] = []; for (let i = 0; i < 7; i++) { const date = new Date(weekStart); date.setDate(weekStart.getDate() + i); dates.push(date); } return dates; } private isToday(date: Date): boolean { const today = new Date(); return date.toDateString() === today.toDateString(); } private minutesToTime(totalMinutes: number): string { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; const period = hours >= 12 ? 'PM' : 'AM'; const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours); return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`; } /** * Scroll to specific hour */ scrollToHour(hour: number): void { if (!this.scrollableContent) return; const hourHeight = calendarConfig.get('hourHeight'); const dayStartHour = calendarConfig.get('dayStartHour'); const scrollTop = (hour - dayStartHour) * hourHeight; this.scrollableContent.scrollTop = scrollTop; } /** * Render grid for a specific container (used during navigation transitions) */ private renderGridForContainer(container: HTMLElement, weekStart: Date): void { // Find the week header and scrollable content within this container const weekHeader = container.querySelector('swp-week-header'); const scrollableContent = container.querySelector('swp-scrollable-content'); const timeGrid = container.querySelector('swp-time-grid'); if (!weekHeader || !scrollableContent || !timeGrid) { console.warn('GridManager: Required elements not found in container'); return; } // Render week header for this container this.renderWeekHeaderForContainer(weekHeader as HTMLElement, weekStart); // Render grid content for this container - pass weekStart this.renderGridForSpecificContainer(container, weekStart); this.renderGridLinesForContainer(timeGrid as HTMLElement); this.setupGridInteractionsForContainer(container); // Setup scroll sync for this new container this.setupScrollSyncForContainer(scrollableContent as HTMLElement); } /** * Render week header for a specific container */ private renderWeekHeaderForContainer(weekHeader: HTMLElement, weekStart: Date): void { const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; weekHeader.innerHTML = ''; for (let i = 0; i < 7; i++) { const date = new Date(weekStart); date.setDate(date.getDate() + i); const header = document.createElement('swp-day-header'); if (this.isToday(date)) { (header as any).dataset.today = 'true'; } header.innerHTML = ` ${days[date.getDay()]} ${date.getDate()} `; (header as any).dataset.date = this.formatDate(date); weekHeader.appendChild(header); } } /** * Render grid structure for a specific container */ private renderGridForSpecificContainer(container: HTMLElement, weekStart?: Date): void { const timeGrid = container.querySelector('swp-time-grid'); if (!timeGrid) { console.warn('GridManager: No time-grid found in container'); return; } // Use the weekStart parameter or fall back to currentWeek const targetWeek = weekStart || this.currentWeek; if (!targetWeek) { console.warn('GridManager: No target week available'); return; } // Clear existing columns let dayColumns = timeGrid.querySelector('swp-day-columns'); if (!dayColumns) { dayColumns = document.createElement('swp-day-columns'); timeGrid.appendChild(dayColumns); } dayColumns.innerHTML = ''; const view = calendarConfig.get('view'); const columnsCount = view === 'week' ? calendarConfig.get('weekDays') : 1; // Create columns using the target week for (let i = 0; i < columnsCount; i++) { const column = document.createElement('swp-day-column'); (column as any).dataset.columnIndex = i; const dates = this.getWeekDates(targetWeek); if (dates[i]) { (column as any).dataset.date = this.formatDate(dates[i]); } // Add events container const eventsLayer = document.createElement('swp-events-layer'); column.appendChild(eventsLayer); dayColumns.appendChild(column); } // Update grid styles for this container const totalHeight = calendarConfig.totalHours * calendarConfig.get('hourHeight'); (timeGrid as HTMLElement).style.height = `${totalHeight}px`; } /** * Render grid lines for a specific time grid */ private renderGridLinesForContainer(timeGrid: HTMLElement): void { let gridLines = timeGrid.querySelector('swp-grid-lines'); if (!gridLines) { gridLines = document.createElement('swp-grid-lines'); timeGrid.insertBefore(gridLines, timeGrid.firstChild); } const totalHours = calendarConfig.totalHours; const hourHeight = calendarConfig.get('hourHeight'); // Set CSS variables timeGrid.style.setProperty('--total-hours', totalHours.toString()); timeGrid.style.setProperty('--hour-height', `${hourHeight}px`); } /** * Setup grid interactions for a specific container */ private setupGridInteractionsForContainer(container: HTMLElement): void { const timeGrid = container.querySelector('swp-time-grid'); if (!timeGrid) return; // Click handler timeGrid.addEventListener('click', (e: Event) => { const mouseEvent = e as MouseEvent; // Ignore if clicking on an event if ((mouseEvent.target as Element).closest('swp-event')) return; const column = (mouseEvent.target as Element).closest('swp-day-column') as HTMLElement; if (!column) return; const position = this.getClickPositionForContainer(mouseEvent, column, container); eventBus.emit(EventTypes.GRID_CLICK, { date: (column as any).dataset.date, time: position.time, minutes: position.minutes, columnIndex: parseInt((column as any).dataset.columnIndex) }); }); // Double click handler timeGrid.addEventListener('dblclick', (e: Event) => { const mouseEvent = e as MouseEvent; // Ignore if clicking on an event if ((mouseEvent.target as Element).closest('swp-event')) return; const column = (mouseEvent.target as Element).closest('swp-day-column') as HTMLElement; if (!column) return; const position = this.getClickPositionForContainer(mouseEvent, column, container); eventBus.emit(EventTypes.GRID_DBLCLICK, { date: (column as any).dataset.date, time: position.time, minutes: position.minutes, columnIndex: parseInt((column as any).dataset.columnIndex) }); }); } /** * Get click position for a specific container */ private getClickPositionForContainer(event: MouseEvent, column: HTMLElement, container: HTMLElement): GridPosition { const rect = column.getBoundingClientRect(); const scrollableContent = container.querySelector('swp-scrollable-content') as HTMLElement; const y = event.clientY - rect.top + (scrollableContent?.scrollTop || 0); const minuteHeight = calendarConfig.minuteHeight; const snapInterval = calendarConfig.get('snapInterval'); const dayStartHour = calendarConfig.get('dayStartHour'); // Calculate minutes from start of day let minutes = Math.floor(y / minuteHeight); // Snap to interval minutes = Math.round(minutes / snapInterval) * snapInterval; // Add day start offset const totalMinutes = (dayStartHour * 60) + minutes; return { minutes: totalMinutes, time: this.minutesToTime(totalMinutes), y: minutes * minuteHeight }; } /** * Setup scroll synchronization between time-axis and scrollable content */ private setupScrollSync(): void { if (!this.scrollableContent || !this.timeAxis) return; // Sync time-axis scroll with scrollable content this.scrollableContent.addEventListener('scroll', () => { if (this.timeAxis) { this.timeAxis.scrollTop = this.scrollableContent!.scrollTop; } }); } /** * Setup scroll synchronization for a specific container's scrollable content */ private setupScrollSyncForContainer(scrollableContent: HTMLElement): void { if (!this.timeAxis) return; // Sync time-axis scroll with this container's scrollable content scrollableContent.addEventListener('scroll', () => { if (this.timeAxis) { this.timeAxis.scrollTop = scrollableContent.scrollTop; } }); } }