Calendar/wwwroot/js/managers/EventStackManager.js
2026-02-03 00:02:25 +01:00

217 lines
No EOL
8.5 KiB
JavaScript

/**
* EventStackManager - Manages visual stacking of overlapping calendar events
*
* This class handles the creation and maintenance of "stack chains" - doubly-linked
* lists of overlapping events stored directly in DOM elements via data attributes.
*
* Implements 3-phase algorithm for grid + nested stacking:
* Phase 1: Group events by start time proximity (configurable threshold)
* Phase 2: Decide container type (GRID vs STACKING)
* Phase 3: Handle late arrivals (nested stacking - NOT IMPLEMENTED)
*
* @see STACKING_CONCEPT.md for detailed documentation
* @see stacking-visualization.html for visual examples
*/
export class EventStackManager {
constructor(config) {
this.config = config;
}
// ============================================
// PHASE 1: Start Time Grouping
// ============================================
/**
* Group events by time conflicts (both start-to-start and end-to-start within threshold)
*
* Events are grouped if:
* 1. They start within ±threshold minutes of each other (start-to-start)
* 2. One event starts within threshold minutes before another ends (end-to-start conflict)
*/
groupEventsByStartTime(events) {
if (events.length === 0)
return [];
// Get threshold from config
const gridSettings = this.config.gridSettings;
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
// Sort events by start time
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const groups = [];
for (const event of sorted) {
// Find existing group that this event conflicts with
const existingGroup = groups.find(group => {
// Check if event conflicts with ANY event in the group
return group.events.some(groupEvent => {
// Start-to-start conflict: events start within threshold
const startToStartMinutes = Math.abs(event.start.getTime() - groupEvent.start.getTime()) / (1000 * 60);
if (startToStartMinutes <= thresholdMinutes) {
return true;
}
// End-to-start conflict: event starts within threshold before groupEvent ends
const endToStartMinutes = (groupEvent.end.getTime() - event.start.getTime()) / (1000 * 60);
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
return true;
}
// Also check reverse: groupEvent starts within threshold before event ends
const reverseEndToStart = (event.end.getTime() - groupEvent.start.getTime()) / (1000 * 60);
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
return true;
}
return false;
});
});
if (existingGroup) {
existingGroup.events.push(event);
}
else {
groups.push({
events: [event],
containerType: 'NONE',
startTime: event.start
});
}
}
return groups;
}
// ============================================
// PHASE 2: Container Type Decision
// ============================================
/**
* Decide container type for a group of events
*
* Rule: Events starting simultaneously (within threshold) should ALWAYS use GRID,
* even if they overlap each other. This provides better visual indication that
* events start at the same time.
*/
decideContainerType(group) {
if (group.events.length === 1) {
return 'NONE';
}
// If events are grouped together (start within threshold), they should share columns (GRID)
// This is true EVEN if they overlap, because the visual priority is to show
// that they start simultaneously.
return 'GRID';
}
/**
* Check if two events overlap in time
*/
doEventsOverlap(event1, event2) {
return event1.start < event2.end && event1.end > event2.start;
}
// ============================================
// Stack Level Calculation
// ============================================
/**
* Create optimized stack links (events share levels when possible)
*/
createOptimizedStackLinks(events) {
const stackLinks = new Map();
if (events.length === 0)
return stackLinks;
// Sort by start time
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
// Step 1: Assign stack levels
for (const event of sorted) {
// Find all events this event overlaps with
const overlapping = sorted.filter(other => other !== event && this.doEventsOverlap(event, other));
// Find the MINIMUM required level (must be above all overlapping events)
let minRequiredLevel = 0;
for (const other of overlapping) {
const otherLink = stackLinks.get(other.id);
if (otherLink) {
// Must be at least one level above the overlapping event
minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1);
}
}
stackLinks.set(event.id, { stackLevel: minRequiredLevel });
}
// Step 2: Build prev/next chains for overlapping events at adjacent stack levels
for (const event of sorted) {
const currentLink = stackLinks.get(event.id);
// Find overlapping events that are directly below (stackLevel - 1)
const overlapping = sorted.filter(other => other !== event && this.doEventsOverlap(event, other));
const directlyBelow = overlapping.filter(other => {
const otherLink = stackLinks.get(other.id);
return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1;
});
if (directlyBelow.length > 0) {
// Use the first one in sorted order as prev
currentLink.prev = directlyBelow[0].id;
}
// Find overlapping events that are directly above (stackLevel + 1)
const directlyAbove = overlapping.filter(other => {
const otherLink = stackLinks.get(other.id);
return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1;
});
if (directlyAbove.length > 0) {
// Use the first one in sorted order as next
currentLink.next = directlyAbove[0].id;
}
}
return stackLinks;
}
/**
* Calculate marginLeft based on stack level
*/
calculateMarginLeft(stackLevel) {
return stackLevel * EventStackManager.STACK_OFFSET_PX;
}
/**
* Calculate zIndex based on stack level
*/
calculateZIndex(stackLevel) {
return 100 + stackLevel;
}
/**
* Serialize stack link to JSON string
*/
serializeStackLink(stackLink) {
return JSON.stringify(stackLink);
}
/**
* Deserialize JSON string to stack link
*/
deserializeStackLink(json) {
try {
return JSON.parse(json);
}
catch (e) {
return null;
}
}
/**
* Apply stack link to DOM element
*/
applyStackLinkToElement(element, stackLink) {
element.dataset.stackLink = this.serializeStackLink(stackLink);
}
/**
* Get stack link from DOM element
*/
getStackLinkFromElement(element) {
const data = element.dataset.stackLink;
if (!data)
return null;
return this.deserializeStackLink(data);
}
/**
* Apply visual styling to element based on stack level
*/
applyVisualStyling(element, stackLevel) {
element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`;
element.style.zIndex = `${this.calculateZIndex(stackLevel)}`;
}
/**
* Clear stack link from element
*/
clearStackLinkFromElement(element) {
delete element.dataset.stackLink;
}
/**
* Clear visual styling from element
*/
clearVisualStyling(element) {
element.style.marginLeft = '';
element.style.zIndex = '';
}
}
EventStackManager.STACK_OFFSET_PX = 15;
//# sourceMappingURL=EventStackManager.js.map