373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 flexbox + nested stacking:
|
||
|
|
* Phase 1: Group events by start time proximity (±15 min threshold)
|
||
|
|
* Phase 2: Decide container type (FLEXBOX vs STACKING)
|
||
|
|
* Phase 3: Handle late arrivals (nested stacking)
|
||
|
|
*
|
||
|
|
* @see STACKING_CONCEPT.md for detailed documentation
|
||
|
|
* @see stacking-visualization.html for visual examples
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
||
|
|
|
||
|
|
export interface StackLink {
|
||
|
|
prev?: string; // Event ID of previous event in stack
|
||
|
|
next?: string; // Event ID of next event in stack
|
||
|
|
stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.)
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EventGroup {
|
||
|
|
events: CalendarEvent[];
|
||
|
|
containerType: 'NONE' | 'GRID' | 'STACKING';
|
||
|
|
startTime: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class EventStackManager {
|
||
|
|
private static readonly FLEXBOX_START_THRESHOLD_MINUTES = 15;
|
||
|
|
private static readonly STACK_OFFSET_PX = 15;
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// PHASE 1: Start Time Grouping
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Group events by start time proximity (±15 min threshold)
|
||
|
|
*/
|
||
|
|
public groupEventsByStartTime(events: CalendarEvent[]): EventGroup[] {
|
||
|
|
if (events.length === 0) return [];
|
||
|
|
|
||
|
|
// Sort events by start time
|
||
|
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||
|
|
|
||
|
|
const groups: EventGroup[] = [];
|
||
|
|
|
||
|
|
for (const event of sorted) {
|
||
|
|
// Find existing group within threshold
|
||
|
|
const existingGroup = groups.find(group => {
|
||
|
|
const groupStart = group.startTime;
|
||
|
|
const diffMinutes = Math.abs(event.start.getTime() - groupStart.getTime()) / (1000 * 60);
|
||
|
|
return diffMinutes <= EventStackManager.FLEXBOX_START_THRESHOLD_MINUTES;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (existingGroup) {
|
||
|
|
existingGroup.events.push(event);
|
||
|
|
} else {
|
||
|
|
groups.push({
|
||
|
|
events: [event],
|
||
|
|
containerType: 'NONE',
|
||
|
|
startTime: event.start
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return groups;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if two events should share flexbox (within ±15 min)
|
||
|
|
*/
|
||
|
|
public shouldShareFlexbox(event1: CalendarEvent, event2: CalendarEvent): boolean {
|
||
|
|
const diffMinutes = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60);
|
||
|
|
return diffMinutes <= EventStackManager.FLEXBOX_START_THRESHOLD_MINUTES;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// PHASE 2: Container Type Decision
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Decide container type for a group of events
|
||
|
|
*
|
||
|
|
* Rule: Events starting simultaneously (within ±15 min) should ALWAYS use GRID,
|
||
|
|
* even if they overlap each other. This provides better visual indication that
|
||
|
|
* events start at the same time.
|
||
|
|
*/
|
||
|
|
public decideContainerType(group: EventGroup): 'NONE' | 'GRID' | 'STACKING' {
|
||
|
|
if (group.events.length === 1) {
|
||
|
|
return 'NONE';
|
||
|
|
}
|
||
|
|
|
||
|
|
// If events are grouped together (start within ±15 min), 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 events within a group overlap each other
|
||
|
|
*/
|
||
|
|
private hasInternalOverlaps(events: CalendarEvent[]): boolean {
|
||
|
|
for (let i = 0; i < events.length; i++) {
|
||
|
|
for (let j = i + 1; j < events.length; j++) {
|
||
|
|
if (this.doEventsOverlap(events[i], events[j])) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if two events overlap in time
|
||
|
|
*/
|
||
|
|
public doEventsOverlap(event1: CalendarEvent, event2: CalendarEvent): boolean {
|
||
|
|
return event1.start < event2.end && event1.end > event2.start;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// PHASE 3: Late Arrivals (Nested Stacking)
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find events that start outside threshold (late arrivals)
|
||
|
|
*/
|
||
|
|
public findLateArrivals(groups: EventGroup[], allEvents: CalendarEvent[]): CalendarEvent[] {
|
||
|
|
const eventsInGroups = new Set(groups.flatMap(g => g.events.map(e => e.id)));
|
||
|
|
return allEvents.filter(event => !eventsInGroups.has(event.id));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find primary parent column for a late event (longest duration or first overlapping)
|
||
|
|
*/
|
||
|
|
public findPrimaryParentColumn(lateEvent: CalendarEvent, flexboxGroup: CalendarEvent[]): string | null {
|
||
|
|
// Find all overlapping events in the flexbox group
|
||
|
|
const overlapping = flexboxGroup.filter(event => this.doEventsOverlap(lateEvent, event));
|
||
|
|
|
||
|
|
if (overlapping.length === 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort by duration (longest first)
|
||
|
|
overlapping.sort((a, b) => {
|
||
|
|
const durationA = b.end.getTime() - b.start.getTime();
|
||
|
|
const durationB = a.end.getTime() - a.start.getTime();
|
||
|
|
return durationA - durationB;
|
||
|
|
});
|
||
|
|
|
||
|
|
return overlapping[0].id;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate marginLeft for nested event (always 15px)
|
||
|
|
*/
|
||
|
|
public calculateNestedMarginLeft(): number {
|
||
|
|
return EventStackManager.STACK_OFFSET_PX;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate stackLevel for nested event (parent + 1)
|
||
|
|
*/
|
||
|
|
public calculateNestedStackLevel(parentStackLevel: number): number {
|
||
|
|
return parentStackLevel + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// Flexbox Layout Calculations
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate flex width for flexbox columns
|
||
|
|
*/
|
||
|
|
public calculateFlexWidth(columnCount: number): string {
|
||
|
|
if (columnCount === 1) return '100%';
|
||
|
|
if (columnCount === 2) return '50%';
|
||
|
|
if (columnCount === 3) return '33.33%';
|
||
|
|
if (columnCount === 4) return '25%';
|
||
|
|
|
||
|
|
// For 5+ columns, calculate percentage
|
||
|
|
const percentage = (100 / columnCount).toFixed(2);
|
||
|
|
return `${percentage}%`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// Existing Methods (from original TDD tests)
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find all events that overlap with a given event
|
||
|
|
*/
|
||
|
|
public findOverlappingEvents(targetEvent: CalendarEvent, columnEvents: CalendarEvent[]): CalendarEvent[] {
|
||
|
|
return columnEvents.filter(event => this.doEventsOverlap(targetEvent, event));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create stack links for overlapping events (naive sequential stacking)
|
||
|
|
*/
|
||
|
|
public createStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
|
||
|
|
const stackLinks = new Map<string, StackLink>();
|
||
|
|
|
||
|
|
if (events.length === 0) return stackLinks;
|
||
|
|
|
||
|
|
// Sort by start time (and by end time if start times are equal)
|
||
|
|
const sorted = [...events].sort((a, b) => {
|
||
|
|
const startDiff = a.start.getTime() - b.start.getTime();
|
||
|
|
if (startDiff !== 0) return startDiff;
|
||
|
|
return a.end.getTime() - b.end.getTime();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create sequential stack
|
||
|
|
sorted.forEach((event, index) => {
|
||
|
|
const link: StackLink = {
|
||
|
|
stackLevel: index
|
||
|
|
};
|
||
|
|
|
||
|
|
if (index > 0) {
|
||
|
|
link.prev = sorted[index - 1].id;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (index < sorted.length - 1) {
|
||
|
|
link.next = sorted[index + 1].id;
|
||
|
|
}
|
||
|
|
|
||
|
|
stackLinks.set(event.id, link);
|
||
|
|
});
|
||
|
|
|
||
|
|
return stackLinks;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create optimized stack links (events share levels when possible)
|
||
|
|
*/
|
||
|
|
public createOptimizedStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
|
||
|
|
const stackLinks = new Map<string, StackLink>();
|
||
|
|
|
||
|
|
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)
|
||
|
|
);
|
||
|
|
|
||
|
|
console.log(`[EventStackManager] Event ${event.id} overlaps with:`, overlapping.map(e => e.id));
|
||
|
|
|
||
|
|
// 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) {
|
||
|
|
console.log(` ${other.id} has stackLevel ${otherLink.stackLevel}`);
|
||
|
|
// Must be at least one level above the overlapping event
|
||
|
|
minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` → Assigned stackLevel ${minRequiredLevel} (must be above all overlapping events)`);
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
public calculateMarginLeft(stackLevel: number): number {
|
||
|
|
return stackLevel * EventStackManager.STACK_OFFSET_PX;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate zIndex based on stack level
|
||
|
|
*/
|
||
|
|
public calculateZIndex(stackLevel: number): number {
|
||
|
|
return 100 + stackLevel;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Serialize stack link to JSON string
|
||
|
|
*/
|
||
|
|
public serializeStackLink(stackLink: StackLink): string {
|
||
|
|
return JSON.stringify(stackLink);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Deserialize JSON string to stack link
|
||
|
|
*/
|
||
|
|
public deserializeStackLink(json: string): StackLink | null {
|
||
|
|
try {
|
||
|
|
return JSON.parse(json);
|
||
|
|
} catch (e) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply stack link to DOM element
|
||
|
|
*/
|
||
|
|
public applyStackLinkToElement(element: HTMLElement, stackLink: StackLink): void {
|
||
|
|
element.dataset.stackLink = this.serializeStackLink(stackLink);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get stack link from DOM element
|
||
|
|
*/
|
||
|
|
public getStackLinkFromElement(element: HTMLElement): StackLink | null {
|
||
|
|
const data = element.dataset.stackLink;
|
||
|
|
if (!data) return null;
|
||
|
|
return this.deserializeStackLink(data);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply visual styling to element based on stack level
|
||
|
|
*/
|
||
|
|
public applyVisualStyling(element: HTMLElement, stackLevel: number): void {
|
||
|
|
element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`;
|
||
|
|
element.style.zIndex = `${this.calculateZIndex(stackLevel)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear stack link from element
|
||
|
|
*/
|
||
|
|
public clearStackLinkFromElement(element: HTMLElement): void {
|
||
|
|
delete element.dataset.stackLink;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear visual styling from element
|
||
|
|
*/
|
||
|
|
public clearVisualStyling(element: HTMLElement): void {
|
||
|
|
element.style.marginLeft = '';
|
||
|
|
element.style.zIndex = '';
|
||
|
|
}
|
||
|
|
}
|