503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
|
|
/**
|
||
|
|
* SimpleEventOverlapManager - Clean, focused overlap management
|
||
|
|
* Eliminates complex state tracking in favor of direct DOM manipulation
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
||
|
|
import { calendarConfig } from '../core/CalendarConfig';
|
||
|
|
|
||
|
|
export enum OverlapType {
|
||
|
|
NONE = 'none',
|
||
|
|
COLUMN_SHARING = 'column_sharing',
|
||
|
|
STACKING = 'stacking'
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface OverlapGroup {
|
||
|
|
type: OverlapType;
|
||
|
|
events: CalendarEvent[];
|
||
|
|
position: { top: number; height: number };
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface StackLink {
|
||
|
|
prev?: string; // Event ID of previous event in stack
|
||
|
|
next?: string; // Event ID of next event in stack
|
||
|
|
stackLevel: number; // 0 = base event, 1 = first stacked, etc
|
||
|
|
}
|
||
|
|
|
||
|
|
export class SimpleEventOverlapManager {
|
||
|
|
private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30;
|
||
|
|
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Detect overlap type between two events - simplified logic
|
||
|
|
*/
|
||
|
|
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
|
||
|
|
if (!this.eventsOverlapInTime(event1, event2)) {
|
||
|
|
return OverlapType.NONE;
|
||
|
|
}
|
||
|
|
|
||
|
|
const timeDiffMinutes = Math.abs(
|
||
|
|
new Date(event1.start).getTime() - new Date(event2.start).getTime()
|
||
|
|
) / (1000 * 60);
|
||
|
|
|
||
|
|
return timeDiffMinutes > SimpleEventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES
|
||
|
|
? OverlapType.STACKING
|
||
|
|
: OverlapType.COLUMN_SHARING;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Simple time overlap check
|
||
|
|
*/
|
||
|
|
private eventsOverlapInTime(event1: CalendarEvent, event2: CalendarEvent): boolean {
|
||
|
|
const start1 = new Date(event1.start).getTime();
|
||
|
|
const end1 = new Date(event1.end).getTime();
|
||
|
|
const start2 = new Date(event2.start).getTime();
|
||
|
|
const end2 = new Date(event2.end).getTime();
|
||
|
|
|
||
|
|
return !(end1 <= start2 || end2 <= start1);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Group overlapping events - much cleaner algorithm
|
||
|
|
*/
|
||
|
|
public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] {
|
||
|
|
const groups: OverlapGroup[] = [];
|
||
|
|
const processed = new Set<string>();
|
||
|
|
|
||
|
|
for (const event of events) {
|
||
|
|
if (processed.has(event.id)) continue;
|
||
|
|
|
||
|
|
// Find all events that overlap with this one
|
||
|
|
const overlapping = events.filter(other => {
|
||
|
|
if (processed.has(other.id)) return false;
|
||
|
|
return other.id === event.id || this.detectOverlap(event, other) !== OverlapType.NONE;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Mark all as processed
|
||
|
|
overlapping.forEach(e => processed.add(e.id));
|
||
|
|
|
||
|
|
// Determine group type
|
||
|
|
const overlapType = overlapping.length > 1
|
||
|
|
? this.detectOverlap(overlapping[0], overlapping[1])
|
||
|
|
: OverlapType.NONE;
|
||
|
|
|
||
|
|
groups.push({
|
||
|
|
type: overlapType,
|
||
|
|
events: overlapping,
|
||
|
|
position: this.calculateGroupPosition(overlapping)
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return groups;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create flexbox container for column sharing - clean and simple
|
||
|
|
*/
|
||
|
|
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
|
||
|
|
const container = document.createElement('swp-event-group');
|
||
|
|
container.style.cssText = `
|
||
|
|
position: absolute;
|
||
|
|
top: ${position.top}px;
|
||
|
|
left: 2px;
|
||
|
|
right: 2px;
|
||
|
|
display: flex;
|
||
|
|
gap: 2px;
|
||
|
|
`;
|
||
|
|
return container;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add event to flexbox group - simple relative positioning
|
||
|
|
*/
|
||
|
|
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
|
||
|
|
// Set duration-based height
|
||
|
|
const duration = eventElement.dataset.duration;
|
||
|
|
if (duration) {
|
||
|
|
const durationMinutes = parseInt(duration);
|
||
|
|
const gridSettings = calendarConfig.getGridSettings();
|
||
|
|
const height = (durationMinutes / 60) * gridSettings.hourHeight;
|
||
|
|
eventElement.style.height = `${height - 3}px`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Flexbox styling
|
||
|
|
eventElement.style.position = 'relative';
|
||
|
|
eventElement.style.flex = '1';
|
||
|
|
eventElement.style.minWidth = '50px';
|
||
|
|
|
||
|
|
container.appendChild(eventElement);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create stacked event with data-attribute tracking
|
||
|
|
*/
|
||
|
|
public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number): void {
|
||
|
|
const marginLeft = stackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
|
||
|
|
|
||
|
|
// Apply visual styling
|
||
|
|
eventElement.style.marginLeft = `${marginLeft}px`;
|
||
|
|
eventElement.style.left = '2px';
|
||
|
|
eventElement.style.right = '2px';
|
||
|
|
eventElement.style.zIndex = `${100 + stackLevel}`;
|
||
|
|
|
||
|
|
// Set up stack linking via data attributes
|
||
|
|
const eventId = eventElement.dataset.eventId;
|
||
|
|
const underlyingId = underlyingElement.dataset.eventId;
|
||
|
|
|
||
|
|
if (!eventId || !underlyingId) {
|
||
|
|
console.warn('Missing event IDs for stack linking:', eventId, underlyingId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Find the last event in the stack chain
|
||
|
|
let lastElement = underlyingElement;
|
||
|
|
let lastLink = this.getStackLink(lastElement);
|
||
|
|
|
||
|
|
// If underlying doesn't have stack link yet, create it
|
||
|
|
if (!lastLink) {
|
||
|
|
this.setStackLink(lastElement, { stackLevel: 0 });
|
||
|
|
lastLink = { stackLevel: 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Traverse to find the end of the chain
|
||
|
|
while (lastLink?.next) {
|
||
|
|
const nextElement = this.findElementById(lastLink.next);
|
||
|
|
if (!nextElement) break;
|
||
|
|
lastElement = nextElement;
|
||
|
|
lastLink = this.getStackLink(lastElement);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Link the new event to the end of the chain
|
||
|
|
const lastElementId = lastElement.dataset.eventId!;
|
||
|
|
this.setStackLink(lastElement, {
|
||
|
|
...lastLink!,
|
||
|
|
next: eventId
|
||
|
|
});
|
||
|
|
|
||
|
|
this.setStackLink(eventElement, {
|
||
|
|
prev: lastElementId,
|
||
|
|
stackLevel: stackLevel
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove stacked styling with proper stack re-linking
|
||
|
|
*/
|
||
|
|
public removeStackedStyling(eventElement: HTMLElement): void {
|
||
|
|
// Clear visual styling
|
||
|
|
eventElement.style.marginLeft = '';
|
||
|
|
eventElement.style.zIndex = '';
|
||
|
|
eventElement.style.left = '2px';
|
||
|
|
eventElement.style.right = '2px';
|
||
|
|
|
||
|
|
// Handle stack chain re-linking
|
||
|
|
const link = this.getStackLink(eventElement);
|
||
|
|
if (link) {
|
||
|
|
// Re-link prev and next events
|
||
|
|
if (link.prev && link.next) {
|
||
|
|
// Middle element - link prev to next
|
||
|
|
const prevElement = this.findElementById(link.prev);
|
||
|
|
const nextElement = this.findElementById(link.next);
|
||
|
|
|
||
|
|
if (prevElement && nextElement) {
|
||
|
|
const prevLink = this.getStackLink(prevElement);
|
||
|
|
const nextLink = this.getStackLink(nextElement);
|
||
|
|
|
||
|
|
this.setStackLink(prevElement, {
|
||
|
|
...prevLink!,
|
||
|
|
next: link.next
|
||
|
|
});
|
||
|
|
|
||
|
|
// FIXED: Use prev's stackLevel + 1 instead of subtracting 1
|
||
|
|
const correctStackLevel = (prevLink?.stackLevel ?? 0) + 1;
|
||
|
|
this.setStackLink(nextElement, {
|
||
|
|
...nextLink!,
|
||
|
|
prev: link.prev,
|
||
|
|
stackLevel: correctStackLevel
|
||
|
|
});
|
||
|
|
|
||
|
|
// CRITICAL: Update visual styling to match new stackLevel
|
||
|
|
const marginLeft = correctStackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
|
||
|
|
nextElement.style.marginLeft = `${marginLeft}px`;
|
||
|
|
nextElement.style.zIndex = `${100 + correctStackLevel}`;
|
||
|
|
}
|
||
|
|
} else if (link.prev) {
|
||
|
|
// Last element - remove next link from prev
|
||
|
|
const prevElement = this.findElementById(link.prev);
|
||
|
|
if (prevElement) {
|
||
|
|
const prevLink = this.getStackLink(prevElement);
|
||
|
|
this.setStackLink(prevElement, {
|
||
|
|
...prevLink!,
|
||
|
|
next: undefined
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else if (link.next) {
|
||
|
|
// First element - remove prev link from next
|
||
|
|
const nextElement = this.findElementById(link.next);
|
||
|
|
if (nextElement) {
|
||
|
|
const nextLink = this.getStackLink(nextElement);
|
||
|
|
this.setStackLink(nextElement, {
|
||
|
|
...nextLink!,
|
||
|
|
prev: undefined,
|
||
|
|
stackLevel: 0 // Next becomes the base event
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// For middle element removal, we've already set the correct stackLevel for next element
|
||
|
|
// Only update subsequent elements after the next one
|
||
|
|
if (link.prev && link.next) {
|
||
|
|
// Middle removal - update elements after the next one
|
||
|
|
const nextElement = this.findElementById(link.next);
|
||
|
|
const nextLink = nextElement ? this.getStackLink(nextElement) : null;
|
||
|
|
if (nextLink?.next) {
|
||
|
|
this.updateSubsequentStackLevels(nextLink.next, -1);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// First or last removal - update all subsequent
|
||
|
|
this.updateSubsequentStackLevels(link.next, -1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear this element's stack link
|
||
|
|
this.setStackLink(eventElement, null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update stack levels for all events following a given event ID
|
||
|
|
*/
|
||
|
|
private updateSubsequentStackLevels(startEventId: string | undefined, levelDelta: number): void {
|
||
|
|
let currentId = startEventId;
|
||
|
|
|
||
|
|
while (currentId) {
|
||
|
|
const currentElement = this.findElementById(currentId);
|
||
|
|
if (!currentElement) break;
|
||
|
|
|
||
|
|
const currentLink = this.getStackLink(currentElement);
|
||
|
|
if (!currentLink) break;
|
||
|
|
|
||
|
|
// Update stack level
|
||
|
|
const newLevel = Math.max(0, currentLink.stackLevel + levelDelta);
|
||
|
|
this.setStackLink(currentElement, {
|
||
|
|
...currentLink,
|
||
|
|
stackLevel: newLevel
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update visual styling
|
||
|
|
const marginLeft = newLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
|
||
|
|
currentElement.style.marginLeft = `${marginLeft}px`;
|
||
|
|
currentElement.style.zIndex = `${100 + newLevel}`;
|
||
|
|
|
||
|
|
currentId = currentLink.next;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if element is stacked - check both style and data-stack-link
|
||
|
|
*/
|
||
|
|
public isStackedEvent(element: HTMLElement): boolean {
|
||
|
|
const marginLeft = element.style.marginLeft;
|
||
|
|
const hasMarginLeft = marginLeft !== '' && marginLeft !== '0px';
|
||
|
|
const hasStackLink = this.getStackLink(element) !== null;
|
||
|
|
|
||
|
|
return hasMarginLeft || hasStackLink;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove event from group with proper cleanup
|
||
|
|
*/
|
||
|
|
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
|
||
|
|
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
|
||
|
|
if (!eventElement) return false;
|
||
|
|
|
||
|
|
// Calculate correct absolute position for standalone event
|
||
|
|
const startTime = eventElement.dataset.start;
|
||
|
|
if (startTime) {
|
||
|
|
const startDate = new Date(startTime);
|
||
|
|
const gridSettings = calendarConfig.getGridSettings();
|
||
|
|
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
||
|
|
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
||
|
|
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
||
|
|
|
||
|
|
// Convert back to absolute positioning
|
||
|
|
eventElement.style.position = 'absolute';
|
||
|
|
eventElement.style.top = `${top + 1}px`;
|
||
|
|
eventElement.style.left = '2px';
|
||
|
|
eventElement.style.right = '2px';
|
||
|
|
eventElement.style.flex = '';
|
||
|
|
eventElement.style.minWidth = '';
|
||
|
|
}
|
||
|
|
|
||
|
|
eventElement.remove();
|
||
|
|
|
||
|
|
// Handle remaining events
|
||
|
|
const remainingEvents = container.querySelectorAll('swp-event');
|
||
|
|
const remainingCount = remainingEvents.length;
|
||
|
|
|
||
|
|
if (remainingCount === 0) {
|
||
|
|
container.remove();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (remainingCount === 1) {
|
||
|
|
const remainingEvent = remainingEvents[0] as HTMLElement;
|
||
|
|
|
||
|
|
// Convert last event back to absolute positioning
|
||
|
|
const remainingStartTime = remainingEvent.dataset.start;
|
||
|
|
if (remainingStartTime) {
|
||
|
|
const remainingStartDate = new Date(remainingStartTime);
|
||
|
|
const gridSettings = calendarConfig.getGridSettings();
|
||
|
|
const remainingStartMinutes = remainingStartDate.getHours() * 60 + remainingStartDate.getMinutes();
|
||
|
|
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
||
|
|
const remainingTop = ((remainingStartMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
||
|
|
|
||
|
|
remainingEvent.style.position = 'absolute';
|
||
|
|
remainingEvent.style.top = `${remainingTop + 1}px`;
|
||
|
|
remainingEvent.style.left = '2px';
|
||
|
|
remainingEvent.style.right = '2px';
|
||
|
|
remainingEvent.style.flex = '';
|
||
|
|
remainingEvent.style.minWidth = '';
|
||
|
|
}
|
||
|
|
|
||
|
|
container.parentElement?.insertBefore(remainingEvent, container);
|
||
|
|
container.remove();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Restack events in container - respects separate stack chains
|
||
|
|
*/
|
||
|
|
public restackEventsInContainer(container: HTMLElement): void {
|
||
|
|
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
|
||
|
|
.filter(el => this.isStackedEvent(el as HTMLElement)) as HTMLElement[];
|
||
|
|
|
||
|
|
if (stackedEvents.length === 0) return;
|
||
|
|
|
||
|
|
// Group events by their stack chains
|
||
|
|
const processedEventIds = new Set<string>();
|
||
|
|
const stackChains: HTMLElement[][] = [];
|
||
|
|
|
||
|
|
for (const element of stackedEvents) {
|
||
|
|
const eventId = element.dataset.eventId;
|
||
|
|
if (!eventId || processedEventIds.has(eventId)) continue;
|
||
|
|
|
||
|
|
// Find the root of this stack chain (stackLevel 0 or no prev link)
|
||
|
|
let rootElement = element;
|
||
|
|
let rootLink = this.getStackLink(rootElement);
|
||
|
|
|
||
|
|
while (rootLink?.prev) {
|
||
|
|
const prevElement = this.findElementById(rootLink.prev);
|
||
|
|
if (!prevElement) break;
|
||
|
|
rootElement = prevElement;
|
||
|
|
rootLink = this.getStackLink(rootElement);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect all elements in this chain
|
||
|
|
const chain: HTMLElement[] = [];
|
||
|
|
let currentElement = rootElement;
|
||
|
|
|
||
|
|
while (currentElement) {
|
||
|
|
chain.push(currentElement);
|
||
|
|
processedEventIds.add(currentElement.dataset.eventId!);
|
||
|
|
|
||
|
|
const currentLink = this.getStackLink(currentElement);
|
||
|
|
if (!currentLink?.next) break;
|
||
|
|
|
||
|
|
const nextElement = this.findElementById(currentLink.next);
|
||
|
|
if (!nextElement) break;
|
||
|
|
currentElement = nextElement;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (chain.length > 1) { // Only add chains with multiple events
|
||
|
|
stackChains.push(chain);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Re-stack each chain separately
|
||
|
|
stackChains.forEach(chain => {
|
||
|
|
chain.forEach((element, index) => {
|
||
|
|
const marginLeft = index * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
|
||
|
|
element.style.marginLeft = `${marginLeft}px`;
|
||
|
|
element.style.zIndex = `${100 + index}`;
|
||
|
|
|
||
|
|
// Update the data-stack-link with correct stackLevel
|
||
|
|
const link = this.getStackLink(element);
|
||
|
|
if (link) {
|
||
|
|
this.setStackLink(element, {
|
||
|
|
...link,
|
||
|
|
stackLevel: index
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate position for group - simplified calculation
|
||
|
|
*/
|
||
|
|
private calculateGroupPosition(events: CalendarEvent[]): { top: number; height: number } {
|
||
|
|
if (events.length === 0) return { top: 0, height: 0 };
|
||
|
|
|
||
|
|
const times = events.flatMap(e => [
|
||
|
|
new Date(e.start).getTime(),
|
||
|
|
new Date(e.end).getTime()
|
||
|
|
]);
|
||
|
|
|
||
|
|
const earliestStart = Math.min(...times);
|
||
|
|
const latestEnd = Math.max(...times);
|
||
|
|
|
||
|
|
const startDate = new Date(earliestStart);
|
||
|
|
const endDate = new Date(latestEnd);
|
||
|
|
|
||
|
|
const gridSettings = calendarConfig.getGridSettings();
|
||
|
|
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
||
|
|
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
|
||
|
|
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
||
|
|
|
||
|
|
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
||
|
|
const height = ((endMinutes - startMinutes) / 60) * gridSettings.hourHeight;
|
||
|
|
|
||
|
|
return { top, height };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Utility methods - simple DOM traversal
|
||
|
|
*/
|
||
|
|
public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
|
||
|
|
return eventElement.closest('swp-event-group') as HTMLElement;
|
||
|
|
}
|
||
|
|
|
||
|
|
public isInEventGroup(element: HTMLElement): boolean {
|
||
|
|
return this.getEventGroup(element) !== null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Helper methods for data-attribute based stack tracking
|
||
|
|
*/
|
||
|
|
public getStackLink(element: HTMLElement): StackLink | null {
|
||
|
|
const linkData = element.dataset.stackLink;
|
||
|
|
if (!linkData) return null;
|
||
|
|
|
||
|
|
try {
|
||
|
|
return JSON.parse(linkData);
|
||
|
|
} catch (e) {
|
||
|
|
console.warn('Failed to parse stack link data:', linkData, e);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private setStackLink(element: HTMLElement, link: StackLink | null): void {
|
||
|
|
if (link === null) {
|
||
|
|
delete element.dataset.stackLink;
|
||
|
|
} else {
|
||
|
|
element.dataset.stackLink = JSON.stringify(link);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private findElementById(eventId: string): HTMLElement | null {
|
||
|
|
return document.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
|
||
|
|
}
|
||
|
|
}
|