Simplifies event overlap management
Refactors event overlap handling to use a DOM-centric approach with data attributes for stack tracking. This eliminates complex state management, reduces code complexity, and improves maintainability. Removes the previous Map-based linked list implementation. The new approach offers better debugging, automatic memory management, and eliminates state synchronization bugs. The solution maintains identical functionality with a significantly simpler implementation, focusing on DOM manipulation for visual stacking and column sharing. Addresses potential performance concerns of DOM queries by scoping them to specific containers.
This commit is contained in:
parent
f5a6b80549
commit
5bdb2f578d
6 changed files with 1699 additions and 67 deletions
503
src/managers/SimpleEventOverlapManager.ts
Normal file
503
src/managers/SimpleEventOverlapManager.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue