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:
Janus Knudsen 2025-09-04 23:35:19 +02:00
parent f5a6b80549
commit 5bdb2f578d
6 changed files with 1699 additions and 67 deletions

View file

@ -24,6 +24,9 @@ export class EventOverlapManager {
private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30;
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
private nextZIndex = 100;
// Linked list til at holde styr på stacked events
private stackChains = new Map<string, { next?: string, prev?: string, stackLevel: number }>();
/**
@ -134,6 +137,9 @@ export class EventOverlapManager {
eventElement.style.height = `${height - 3}px`; // -3px som andre events
}
// Events i flexbox grupper skal bruge relative positioning
eventElement.style.position = 'relative';
container.appendChild(eventElement);
}
@ -144,8 +150,28 @@ export class EventOverlapManager {
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
if (!eventElement) return false;
// Gendan absolute positioning
eventElement.style.position = 'absolute';
// Tjek om det fjernede event var stacked
const wasStacked = this.isStackedEvent(eventElement);
// Beregn korrekt top position baseret på event data
const startTime = eventElement.dataset.start;
if (startTime) {
const startDate = new Date(startTime);
const gridSettings = { dayStartHour: 6, hourHeight: 80 }; // Fra config
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
// Gendan absolute positioning med korrekt top position
eventElement.style.position = 'absolute';
eventElement.style.top = `${top + 1}px`; // +1px som andre events
eventElement.style.left = '2px';
eventElement.style.right = '2px';
// Fjern stacking styling
eventElement.style.marginLeft = '';
eventElement.style.zIndex = '';
}
eventElement.remove();
// Tæl resterende events
@ -161,11 +187,25 @@ export class EventOverlapManager {
// Hvis kun ét event tilbage, konvertér tilbage til normal event
if (remainingCount === 1) {
const remainingEvent = remainingEvents[0] as HTMLElement;
// Gendan normal event positioning
remainingEvent.style.position = 'absolute';
remainingEvent.style.top = container.style.top;
remainingEvent.style.left = '2px';
remainingEvent.style.right = '2px';
// Beregn korrekt top position for remaining event
const remainingStartTime = remainingEvent.dataset.start;
if (remainingStartTime) {
const remainingStartDate = new Date(remainingStartTime);
const gridSettings = { dayStartHour: 6, hourHeight: 80 };
const remainingStartMinutes = remainingStartDate.getHours() * 60 + remainingStartDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const remainingTop = ((remainingStartMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
// Gendan normal event positioning (absolute for standalone events)
remainingEvent.style.position = 'absolute';
remainingEvent.style.top = `${remainingTop + 1}px`; // +1px som andre events
remainingEvent.style.left = '2px';
remainingEvent.style.right = '2px';
// Fjern eventuel stacking styling
remainingEvent.style.marginLeft = '';
remainingEvent.style.zIndex = '';
}
// Indsæt før container og fjern container
container.parentElement?.insertBefore(remainingEvent, container);
@ -173,6 +213,31 @@ export class EventOverlapManager {
return true; // Container blev fjernet
}
// Altid tjek for stack chain cleanup, uanset wasStacked flag
const removedEventId = eventElement.dataset.eventId;
console.log('Checking stack chain for removed event:', removedEventId, 'Has chain:', this.stackChains.has(removedEventId || ''));
if (removedEventId && this.stackChains.has(removedEventId)) {
console.log('Removing from stack chain:', removedEventId);
const affectedEventIds = this.removeFromStackChain(removedEventId);
console.log('Affected events:', affectedEventIds);
// Opdater margin-left for påvirkede events
affectedEventIds.forEach((affectedId: string) => {
const affectedElement = container.querySelector(`swp-event[data-event-id="${affectedId}"]`) as HTMLElement;
console.log('Found affected element:', affectedId, !!affectedElement);
if (affectedElement) {
const chainInfo = this.stackChains.get(affectedId);
if (chainInfo) {
const newMarginLeft = chainInfo.stackLevel * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
console.log('Updating margin-left for', affectedId, 'from', affectedElement.style.marginLeft, 'to', newMarginLeft + 'px');
affectedElement.style.marginLeft = `${newMarginLeft}px`;
}
}
});
}
return false; // Container blev ikke fjernet
}
@ -188,19 +253,130 @@ export class EventOverlapManager {
eventElement.style.right = '2px';
eventElement.style.width = '';
eventElement.style.zIndex = this.getNextZIndex().toString();
// Tilføj til stack chain
const eventId = eventElement.dataset.eventId;
const underlyingId = underlyingElement.dataset.eventId;
console.log('STACK CHAIN ADD: Adding', eventId, 'to chain with underlying', underlyingId, 'at stackLevel', stackLevel);
if (eventId && underlyingId) {
// Find sidste event i chain
let lastEventId = underlyingId;
while (this.stackChains.has(lastEventId) && this.stackChains.get(lastEventId)?.next) {
lastEventId = this.stackChains.get(lastEventId)!.next!;
}
console.log('STACK CHAIN ADD: Last event in chain is', lastEventId);
// Link det nye event til chain
if (!this.stackChains.has(lastEventId)) {
this.stackChains.set(lastEventId, { stackLevel: 0 });
console.log('STACK CHAIN ADD: Created chain entry for underlying event', lastEventId);
}
this.stackChains.get(lastEventId)!.next = eventId;
this.stackChains.set(eventId, { prev: lastEventId, stackLevel });
console.log('STACK CHAIN ADD: Linked', lastEventId, '->', eventId);
console.log('STACK CHAIN STATE:', Array.from(this.stackChains.entries()));
}
}
/**
* Fjern stacking styling fra event
*/
public removeStackedStyling(eventElement: HTMLElement): void {
const eventId = eventElement.dataset.eventId;
console.log('removeStackedStyling called for:', eventId);
eventElement.style.marginLeft = '';
eventElement.style.width = '';
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.zIndex = '';
// Fjern fra stack chain og opdater andre events
if (eventId && this.stackChains.has(eventId)) {
console.log('Removing from stack chain and updating affected events:', eventId);
const affectedEventIds = this.removeFromStackChain(eventId);
console.log('Affected events from removeFromStackChain:', affectedEventIds);
// Find den kolonne hvor eventet var placeret
const columnElement = eventElement.closest('swp-events-layer');
if (columnElement) {
console.log('Found column element, updating affected events');
// Opdater margin-left for ALLE resterende events baseret på deres index
affectedEventIds.forEach((affectedId: string, index: number) => {
const affectedElement = columnElement.querySelector(`swp-event[data-event-id="${affectedId}"]`) as HTMLElement;
console.log('Looking for affected element:', affectedId, 'found:', !!affectedElement);
if (affectedElement) {
// Index 0 = 0px margin, index 1 = 15px margin, index 2 = 30px margin, osv.
const newMarginLeft = index * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
console.log('Updating margin-left for', affectedId, 'at index', index, 'from', affectedElement.style.marginLeft, 'to', newMarginLeft + 'px');
affectedElement.style.marginLeft = `${newMarginLeft}px`;
}
});
} else {
console.log('No column element found for updating affected events');
}
}
}
/**
* Fjern event fra stack chain og re-stack resterende events
*/
private removeFromStackChain(eventId: string): string[] {
console.log('STACK CHAIN REMOVE: Removing', eventId, 'from chain');
console.log('STACK CHAIN STATE BEFORE:', Array.from(this.stackChains.entries()));
// Fjern eventet fra chain
this.stackChains.delete(eventId);
// Find ALLE resterende events i stackChains og returner dem
const allRemainingEventIds = Array.from(this.stackChains.keys());
console.log('STACK CHAIN REMOVE: All remaining events to re-stack:', allRemainingEventIds);
// Re-assign stackLevel baseret på position (0 = underlying, 1 = første stacked, osv.)
allRemainingEventIds.forEach((remainingId, index) => {
const chainInfo = this.stackChains.get(remainingId);
if (chainInfo) {
chainInfo.stackLevel = index;
console.log('STACK CHAIN REMOVE: Set stackLevel for', remainingId, 'to', index);
}
});
console.log('STACK CHAIN STATE AFTER:', Array.from(this.stackChains.entries()));
return allRemainingEventIds;
}
/**
* Re-stack events efter fjernelse af et stacked event
*/
private restackRemainingEvents(container: HTMLElement): void {
// Find alle stacked events (events med margin-left)
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
.filter(el => {
const element = el as HTMLElement;
return element.style.marginLeft && element.style.marginLeft !== '0px';
}) as HTMLElement[];
if (stackedEvents.length === 0) return;
// Sort events by current margin-left (ascending)
stackedEvents.sort((a, b) => {
const marginA = parseInt(a.style.marginLeft) || 0;
const marginB = parseInt(b.style.marginLeft) || 0;
return marginA - marginB;
});
// Re-assign margin-left values starting from 15px
stackedEvents.forEach((element, index) => {
const newMarginLeft = (index + 1) * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
element.style.marginLeft = `${newMarginLeft}px`;
});
}
/**
* Beregn position for event gruppe
@ -256,7 +432,14 @@ export class EventOverlapManager {
* Check if element is a stacked event
*/
public isStackedEvent(element: HTMLElement): boolean {
return element.style.marginLeft !== '' && element.style.marginLeft !== '0px';
const eventId = element.dataset.eventId;
const hasMarginLeft = element.style.marginLeft !== '' && element.style.marginLeft !== '0px';
const isInStackChain = eventId ? this.stackChains.has(eventId) : false;
console.log('isStackedEvent check:', eventId, 'hasMarginLeft:', hasMarginLeft, 'isInStackChain:', isInStackChain);
// Et event er stacked hvis det enten har margin-left ELLER er i en stack chain
return hasMarginLeft || isInStackChain;
}
/**

View 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;
}
}

View file

@ -6,7 +6,7 @@ import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { EventOverlapManager, OverlapType } from '../managers/EventOverlapManager';
import { SimpleEventOverlapManager, OverlapType } from '../managers/SimpleEventOverlapManager';
/**
* Interface for event rendering strategies
@ -21,7 +21,7 @@ export interface EventRendererStrategy {
*/
export abstract class BaseEventRenderer implements EventRendererStrategy {
protected dateCalculator: DateCalculator;
protected overlapManager: EventOverlapManager;
protected overlapManager: SimpleEventOverlapManager;
// Drag and drop state
private draggedClone: HTMLElement | null = null;
@ -32,7 +32,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
DateCalculator.initialize(calendarConfig);
}
this.dateCalculator = dateCalculator || new DateCalculator();
this.overlapManager = new EventOverlapManager();
this.overlapManager = new SimpleEventOverlapManager();
}
/**
@ -229,9 +229,10 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
* Handle drag start event
*/
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void {
console.log('handleDragStart:', eventId);
this.originalEvent = originalElement;
// Remove stacking styling from original event before creating clone
// Remove stacking styling during drag
if (this.overlapManager.isStackedEvent(originalElement)) {
this.overlapManager.removeStackedStyling(originalElement);
}
@ -294,8 +295,10 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
* Handle drag end event
*/
private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void {
console.log('handleDragEnd:', eventId);
if (!this.draggedClone || !this.originalEvent) {
console.log('Missing draggedClone or originalEvent');
return;
}
@ -331,16 +334,18 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
*/
private removeEventFromExistingGroups(eventElement: HTMLElement): void {
const eventGroup = this.overlapManager.getEventGroup(eventElement);
if (eventGroup) {
const eventId = eventElement.dataset.eventId;
if (eventId) {
this.overlapManager.removeFromEventGroup(eventGroup, eventId);
// Gendan normal kolonne bredde efter fjernelse fra group
this.restoreNormalEventStyling(eventElement);
}
const eventId = eventElement.dataset.eventId;
if (eventGroup && eventId) {
// Remove from flexbox group
this.overlapManager.removeFromEventGroup(eventGroup, eventId);
} else if (this.overlapManager.isStackedEvent(eventElement)) {
// Remove stacking styling if it's a stacked event
// Remove stacking styling and restack others
this.overlapManager.removeStackedStyling(eventElement);
const container = eventElement.closest('swp-events-layer') as HTMLElement;
if (container) {
this.overlapManager.restackEventsInContainer(container);
}
}
}
@ -367,8 +372,9 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
const height2 = parseFloat(element2.style.height) || 0;
const bottom2 = top2 + height2;
// Check if events overlap in time (pixel space)
if (bottom1 <= top2 || bottom2 <= top1) {
// Check if events overlap in pixel space (with small tolerance for borders)
const tolerance = 2; // Account for borders and small gaps
if (bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance)) {
return OverlapType.NONE;
}
@ -384,6 +390,85 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
return OverlapType.COLUMN_SHARING;
}
/**
* Detect and group overlapping events during initial rendering
*/
private detectAndGroupInitialEvents(renderedElements: HTMLElement[], container: Element): void {
const processedElements = new Set<HTMLElement>();
for (const element of renderedElements) {
if (processedElements.has(element)) continue;
const overlappingElements: HTMLElement[] = [element];
processedElements.add(element);
// Find alle elements der overlapper med dette element
for (const otherElement of renderedElements) {
if (otherElement === element || processedElements.has(otherElement)) continue;
const overlapType = this.detectPixelOverlap(element, otherElement);
if (overlapType !== OverlapType.NONE) {
overlappingElements.push(otherElement);
processedElements.add(otherElement);
}
}
// Hvis der er overlaps, group dem
if (overlappingElements.length > 1) {
const overlapType = this.detectPixelOverlap(overlappingElements[0], overlappingElements[1]);
// Fjern overlapping elements fra DOM
overlappingElements.forEach(el => el.remove());
// Konvertér til CalendarEvent objekter
const overlappingEvents: CalendarEvent[] = [];
for (const el of overlappingElements) {
const event = this.elementToCalendarEvent(el);
if (event) {
overlappingEvents.push(event);
}
}
if (overlapType === OverlapType.COLUMN_SHARING) {
// Create column sharing group
const groupContainer = this.overlapManager.createEventGroup(overlappingEvents, { top: 0, height: 0 });
overlappingEvents.forEach(event => {
const eventElement = this.createEventElement(event);
this.positionEvent(eventElement, event);
this.overlapManager.addToEventGroup(groupContainer, eventElement);
});
container.appendChild(groupContainer);
} else if (overlapType === OverlapType.STACKING) {
// Handle stacking
const sortedEvents = [...overlappingEvents].sort((a, b) => {
const durationA = new Date(a.end).getTime() - new Date(a.start).getTime();
const durationB = new Date(b.end).getTime() - new Date(b.start).getTime();
return durationB - durationA;
});
let underlyingElement: HTMLElement | null = null;
sortedEvents.forEach((event, index) => {
const eventElement = this.createEventElement(event);
this.positionEvent(eventElement, event);
if (index === 0) {
container.appendChild(eventElement);
underlyingElement = eventElement;
} else {
if (underlyingElement) {
this.overlapManager.createStackedEvent(eventElement, underlyingElement, index);
}
container.appendChild(eventElement);
}
});
}
}
}
}
/**
* Detect overlaps with other events in target column and handle repositioning
*/
@ -429,17 +514,30 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// Check if dropped event overlaps with any existing events
let hasOverlaps = false;
const overlappingEvents: CalendarEvent[] = [];
let overlapType: OverlapType = OverlapType.NONE;
for (const existingElement of existingEvents) {
// Skip if it's the same event (comparing IDs)
if (existingElement.dataset.eventId === droppedEvent.id) continue;
const overlapType = this.detectPixelOverlap(droppedElement, existingElement);
if (overlapType !== OverlapType.NONE) {
const currentOverlapType = this.detectPixelOverlap(droppedElement, existingElement);
if (currentOverlapType !== OverlapType.NONE) {
hasOverlaps = true;
const existingEvent = this.elementToCalendarEvent(existingElement);
if (existingEvent) {
overlappingEvents.push(existingEvent);
// Use the first detected overlap type for consistency
if (overlapType === OverlapType.NONE) {
overlapType = currentOverlapType;
}
// CRITICAL FIX: Include the entire stack chain, not just the directly overlapping event
const stackChain = this.getFullStackChain(existingElement);
const alreadyIncludedIds = new Set(overlappingEvents.map(e => e.id));
for (const chainElement of stackChain) {
const chainEvent = this.elementToCalendarEvent(chainElement);
if (chainEvent && !alreadyIncludedIds.has(chainEvent.id)) {
overlappingEvents.push(chainEvent);
alreadyIncludedIds.add(chainEvent.id);
}
}
}
}
@ -454,30 +552,61 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
return;
}
// There are overlaps - group and re-render overlapping events
const overlapGroups = this.overlapManager.groupOverlappingEvents(overlappingEvents);
// There are overlaps - use the detected overlap type
// Remove overlapping events from DOM
const overlappingEventIds = new Set(overlappingEvents.map(e => e.id));
existingEvents
.filter(el => overlappingEventIds.has(el.dataset.eventId || ''))
.forEach(el => el.remove());
droppedElement.remove();
// Re-render overlapping events with proper grouping
overlapGroups.forEach(group => {
if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) {
this.renderColumnSharingGroup(group, eventsLayer);
} else if (group.type === OverlapType.STACKING && group.events.length > 1) {
this.renderStackedEvents(group, eventsLayer);
} else {
group.events.forEach(event => {
const eventElement = this.createEventElement(event);
this.positionEvent(eventElement, event);
if (overlapType === OverlapType.COLUMN_SHARING) {
// Create column sharing group
const groupContainer = this.overlapManager.createEventGroup(overlappingEvents, { top: 0, height: 0 });
// Remove overlapping events from DOM
const overlappingEventIds = new Set(overlappingEvents.map(e => e.id));
existingEvents
.filter(el => overlappingEventIds.has(el.dataset.eventId || ''))
.forEach(el => el.remove());
droppedElement.remove();
// Add all events to the group
overlappingEvents.forEach(event => {
const eventElement = this.createEventElement(event);
this.positionEvent(eventElement, event);
this.overlapManager.addToEventGroup(groupContainer, eventElement);
});
eventsLayer.appendChild(groupContainer);
} else if (overlapType === OverlapType.STACKING) {
// Handle stacking - sort by duration and stack shorter events on top
const sortedEvents = [...overlappingEvents].sort((a, b) => {
const durationA = new Date(a.end).getTime() - new Date(a.start).getTime();
const durationB = new Date(b.end).getTime() - new Date(b.start).getTime();
return durationB - durationA; // Longer duration first (background)
});
// Remove overlapping events from DOM
const overlappingEventIds = new Set(overlappingEvents.map(e => e.id));
existingEvents
.filter(el => overlappingEventIds.has(el.dataset.eventId || ''))
.forEach(el => el.remove());
droppedElement.remove();
let underlyingElement: HTMLElement | null = null;
sortedEvents.forEach((event, index) => {
const eventElement = this.createEventElement(event);
this.positionEvent(eventElement, event);
if (index === 0) {
// First (longest duration) event renders normally at full width
eventsLayer.appendChild(eventElement);
});
}
});
underlyingElement = eventElement;
} else {
// Shorter events are stacked with margin-left offset and higher z-index
if (underlyingElement) {
this.overlapManager.createStackedEvent(eventElement, underlyingElement, index);
}
eventsLayer.appendChild(eventElement);
}
});
}
}
/**
@ -545,6 +674,40 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
};
}
/**
* Get the full stack chain for an event element
*/
private getFullStackChain(element: HTMLElement): HTMLElement[] {
const chain: HTMLElement[] = [];
// Find root of the stack chain (element with stackLevel 0 or no prev link)
let rootElement = element;
let rootLink = this.overlapManager.getStackLink(rootElement);
// Walk backwards to find root
while (rootLink?.prev) {
const prevElement = document.querySelector(`swp-event[data-event-id="${rootLink.prev}"]`) as HTMLElement;
if (!prevElement) break;
rootElement = prevElement;
rootLink = this.overlapManager.getStackLink(rootElement);
}
// Collect entire chain from root forward
let currentElement = rootElement;
while (currentElement) {
chain.push(currentElement);
const currentLink = this.overlapManager.getStackLink(currentElement);
if (!currentLink?.next) break;
const nextElement = document.querySelector(`swp-event[data-event-id="${currentLink.next}"]`) as HTMLElement;
if (!nextElement) break;
currentElement = nextElement;
}
return chain;
}
/**
* Convert DOM element to CalendarEvent for overlap detection
*/
@ -732,24 +895,19 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
// Group events by overlap type
const overlapGroups = this.overlapManager.groupOverlappingEvents(columnEvents);
overlapGroups.forEach(group => {
if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) {
// Create flexbox container for column sharing
this.renderColumnSharingGroup(group, eventsLayer);
} else if (group.type === OverlapType.STACKING && group.events.length > 1) {
// Render stacked events
this.renderStackedEvents(group, eventsLayer);
} else {
// Render normal single events
group.events.forEach(event => {
this.renderEvent(event, eventsLayer);
});
// Render events først, så vi kan få deres pixel positioner
const renderedElements: HTMLElement[] = [];
columnEvents.forEach(event => {
this.renderEvent(event, eventsLayer);
const eventElement = eventsLayer.querySelector(`swp-event[data-event-id="${event.id}"]`) as HTMLElement;
if (eventElement) {
renderedElements.push(eventElement);
}
});
// Nu detect overlaps baseret på pixel positioner
this.detectAndGroupInitialEvents(renderedElements, eventsLayer);
// Debug: Verify events were actually added
const renderedEvents = eventsLayer.querySelectorAll('swp-event, swp-event-group');
} else {
@ -1015,9 +1173,8 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
} else {
// Shorter events are stacked with margin-left offset and higher z-index
// Each subsequent event gets more margin: 15px, 30px, 45px, etc.
if (underlyingElement) {
this.overlapManager.createStackedEvent(eventElement, underlyingElement, index);
}
// Use simplified stacking - no complex chain tracking
this.overlapManager.createStackedEvent(eventElement, underlyingElement!, index);
container.appendChild(eventElement);
// DO NOT update underlyingElement - keep it as the longest event
}