201 lines
9.1 KiB
JavaScript
201 lines
9.1 KiB
JavaScript
|
|
/**
|
||
|
|
* EventLayoutCoordinator - Coordinates event layout calculations
|
||
|
|
*
|
||
|
|
* Separates layout logic from rendering concerns.
|
||
|
|
* Calculates stack levels, groups events, and determines rendering strategy.
|
||
|
|
*/
|
||
|
|
export class EventLayoutCoordinator {
|
||
|
|
constructor(stackManager, config, positionUtils) {
|
||
|
|
this.stackManager = stackManager;
|
||
|
|
this.config = config;
|
||
|
|
this.positionUtils = positionUtils;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Calculate complete layout for a column of events (recursive approach)
|
||
|
|
*/
|
||
|
|
calculateColumnLayout(columnEvents) {
|
||
|
|
if (columnEvents.length === 0) {
|
||
|
|
return { gridGroups: [], stackedEvents: [] };
|
||
|
|
}
|
||
|
|
const gridGroupLayouts = [];
|
||
|
|
const stackedEventLayouts = [];
|
||
|
|
const renderedEventsWithLevels = [];
|
||
|
|
let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||
|
|
// Process events recursively
|
||
|
|
while (remaining.length > 0) {
|
||
|
|
// Take first event
|
||
|
|
const firstEvent = remaining[0];
|
||
|
|
// Find events that could be in GRID with first event
|
||
|
|
// Use expanding search to find chains (A→B→C where each conflicts with next)
|
||
|
|
const gridSettings = this.config.gridSettings;
|
||
|
|
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
||
|
|
// Use refactored method for expanding grid candidates
|
||
|
|
const gridCandidates = this.expandGridCandidates(firstEvent, remaining, thresholdMinutes);
|
||
|
|
// Decide: should this group be GRID or STACK?
|
||
|
|
const group = {
|
||
|
|
events: gridCandidates,
|
||
|
|
containerType: 'NONE',
|
||
|
|
startTime: firstEvent.start
|
||
|
|
};
|
||
|
|
const containerType = this.stackManager.decideContainerType(group);
|
||
|
|
if (containerType === 'GRID' && gridCandidates.length > 1) {
|
||
|
|
// Render as GRID
|
||
|
|
const gridStackLevel = this.calculateGridGroupStackLevelFromRendered(gridCandidates, renderedEventsWithLevels);
|
||
|
|
// Ensure we get the earliest event (explicit sort for robustness)
|
||
|
|
const earliestEvent = [...gridCandidates].sort((a, b) => a.start.getTime() - b.start.getTime())[0];
|
||
|
|
const position = this.positionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
|
||
|
|
const columns = this.allocateColumns(gridCandidates);
|
||
|
|
gridGroupLayouts.push({
|
||
|
|
events: gridCandidates,
|
||
|
|
stackLevel: gridStackLevel,
|
||
|
|
position: { top: position.top + 1 },
|
||
|
|
columns
|
||
|
|
});
|
||
|
|
// Mark all events in grid with their stack level
|
||
|
|
gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel }));
|
||
|
|
// Remove all events in this grid from remaining
|
||
|
|
remaining = remaining.filter(e => !gridCandidates.includes(e));
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// Render first event as STACKED
|
||
|
|
const stackLevel = this.calculateStackLevelFromRendered(firstEvent, renderedEventsWithLevels);
|
||
|
|
const position = this.positionUtils.calculateEventPosition(firstEvent.start, firstEvent.end);
|
||
|
|
stackedEventLayouts.push({
|
||
|
|
event: firstEvent,
|
||
|
|
stackLink: { stackLevel },
|
||
|
|
position: { top: position.top + 1, height: position.height - 3 }
|
||
|
|
});
|
||
|
|
// Mark this event with its stack level
|
||
|
|
renderedEventsWithLevels.push({ event: firstEvent, level: stackLevel });
|
||
|
|
// Remove only first event from remaining
|
||
|
|
remaining = remaining.slice(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
gridGroups: gridGroupLayouts,
|
||
|
|
stackedEvents: stackedEventLayouts
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Calculate stack level for a grid group based on already rendered events
|
||
|
|
*/
|
||
|
|
calculateGridGroupStackLevelFromRendered(gridEvents, renderedEventsWithLevels) {
|
||
|
|
// Find highest stack level of any rendered event that overlaps with this grid
|
||
|
|
let maxOverlappingLevel = -1;
|
||
|
|
for (const gridEvent of gridEvents) {
|
||
|
|
for (const rendered of renderedEventsWithLevels) {
|
||
|
|
if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) {
|
||
|
|
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return maxOverlappingLevel + 1;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Calculate stack level for a single stacked event based on already rendered events
|
||
|
|
*/
|
||
|
|
calculateStackLevelFromRendered(event, renderedEventsWithLevels) {
|
||
|
|
// Find highest stack level of any rendered event that overlaps with this event
|
||
|
|
let maxOverlappingLevel = -1;
|
||
|
|
for (const rendered of renderedEventsWithLevels) {
|
||
|
|
if (this.stackManager.doEventsOverlap(event, rendered.event)) {
|
||
|
|
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return maxOverlappingLevel + 1;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Detect if two events have a conflict based on threshold
|
||
|
|
*
|
||
|
|
* @param event1 - First event
|
||
|
|
* @param event2 - Second event
|
||
|
|
* @param thresholdMinutes - Threshold in minutes
|
||
|
|
* @returns true if events conflict
|
||
|
|
*/
|
||
|
|
detectConflict(event1, event2, thresholdMinutes) {
|
||
|
|
// Check 1: Start-to-start conflict (starts within threshold)
|
||
|
|
const startToStartDiff = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60);
|
||
|
|
if (startToStartDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(event1, event2)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
// Check 2: End-to-start conflict (event1 starts within threshold before event2 ends)
|
||
|
|
const endToStartMinutes = (event2.end.getTime() - event1.start.getTime()) / (1000 * 60);
|
||
|
|
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
// Check 3: Reverse end-to-start (event2 starts within threshold before event1 ends)
|
||
|
|
const reverseEndToStart = (event1.end.getTime() - event2.start.getTime()) / (1000 * 60);
|
||
|
|
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Expand grid candidates to find all events connected by conflict chains
|
||
|
|
*
|
||
|
|
* Uses expanding search to find chains (A→B→C where each conflicts with next)
|
||
|
|
*
|
||
|
|
* @param firstEvent - The first event to start with
|
||
|
|
* @param remaining - Remaining events to check
|
||
|
|
* @param thresholdMinutes - Threshold in minutes
|
||
|
|
* @returns Array of all events in the conflict chain
|
||
|
|
*/
|
||
|
|
expandGridCandidates(firstEvent, remaining, thresholdMinutes) {
|
||
|
|
const gridCandidates = [firstEvent];
|
||
|
|
let candidatesChanged = true;
|
||
|
|
// Keep expanding until no new candidates can be added
|
||
|
|
while (candidatesChanged) {
|
||
|
|
candidatesChanged = false;
|
||
|
|
for (let i = 1; i < remaining.length; i++) {
|
||
|
|
const candidate = remaining[i];
|
||
|
|
// Skip if already in candidates
|
||
|
|
if (gridCandidates.includes(candidate))
|
||
|
|
continue;
|
||
|
|
// Check if candidate conflicts with ANY event in gridCandidates
|
||
|
|
for (const existingCandidate of gridCandidates) {
|
||
|
|
if (this.detectConflict(candidate, existingCandidate, thresholdMinutes)) {
|
||
|
|
gridCandidates.push(candidate);
|
||
|
|
candidatesChanged = true;
|
||
|
|
break; // Found conflict, move to next candidate
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return gridCandidates;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Allocate events to columns within a grid group
|
||
|
|
*
|
||
|
|
* Events that don't overlap can share the same column.
|
||
|
|
* Uses a greedy algorithm to minimize the number of columns.
|
||
|
|
*
|
||
|
|
* @param events - Events in the grid group (should already be sorted by start time)
|
||
|
|
* @returns Array of columns, where each column is an array of events
|
||
|
|
*/
|
||
|
|
allocateColumns(events) {
|
||
|
|
if (events.length === 0)
|
||
|
|
return [];
|
||
|
|
if (events.length === 1)
|
||
|
|
return [[events[0]]];
|
||
|
|
const columns = [];
|
||
|
|
// For each event, try to place it in an existing column where it doesn't overlap
|
||
|
|
for (const event of events) {
|
||
|
|
let placed = false;
|
||
|
|
// Try to find a column where this event doesn't overlap with any existing event
|
||
|
|
for (const column of columns) {
|
||
|
|
const hasOverlap = column.some(colEvent => this.stackManager.doEventsOverlap(event, colEvent));
|
||
|
|
if (!hasOverlap) {
|
||
|
|
column.push(event);
|
||
|
|
placed = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// If no suitable column found, create a new one
|
||
|
|
if (!placed) {
|
||
|
|
columns.push([event]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return columns;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=EventLayoutCoordinator.js.map
|