217 lines
No EOL
8.5 KiB
JavaScript
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
|