Improves event layout and stacking logic
Refactors the event layout and stacking logic based on review feedback. This includes: - Merging conflicting event groups to prevent inconsistencies. - Implementing minimal stack level assignment using a min-heap. - Consolidating styling and using DateService for drag operations. - Adding reflow after drag and drop. - Improving the column event filtering to include events overlapping midnight. - Ensuring explicit sorting of events for grid layout.
This commit is contained in:
parent
b590467f60
commit
faa59f6a3c
19 changed files with 1502 additions and 55 deletions
|
|
@ -58,49 +58,8 @@ export class EventLayoutCoordinator {
|
|||
const gridSettings = calendarConfig.getGridSettings();
|
||||
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
||||
|
||||
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) {
|
||||
let hasConflict = false;
|
||||
|
||||
// Check 1: Start-to-start conflict (starts within threshold)
|
||||
const startToStartDiff = Math.abs(candidate.start.getTime() - existingCandidate.start.getTime()) / (1000 * 60);
|
||||
if (startToStartDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(candidate, existingCandidate)) {
|
||||
hasConflict = true;
|
||||
}
|
||||
|
||||
// Check 2: End-to-start conflict (candidate starts within threshold before existingCandidate ends)
|
||||
const endToStartMinutes = (existingCandidate.end.getTime() - candidate.start.getTime()) / (1000 * 60);
|
||||
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
|
||||
hasConflict = true;
|
||||
}
|
||||
|
||||
// Check 3: Reverse end-to-start (existingCandidate starts within threshold before candidate ends)
|
||||
const reverseEndToStart = (candidate.end.getTime() - existingCandidate.start.getTime()) / (1000 * 60);
|
||||
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
|
||||
hasConflict = true;
|
||||
}
|
||||
|
||||
if (hasConflict) {
|
||||
gridCandidates.push(candidate);
|
||||
candidatesChanged = true;
|
||||
break; // Found conflict, move to next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use refactored method for expanding grid candidates
|
||||
const gridCandidates = this.expandGridCandidates(firstEvent, remaining, thresholdMinutes);
|
||||
|
||||
// Decide: should this group be GRID or STACK?
|
||||
const group: EventGroup = {
|
||||
|
|
@ -117,7 +76,8 @@ export class EventLayoutCoordinator {
|
|||
renderedEventsWithLevels
|
||||
);
|
||||
|
||||
const earliestEvent = gridCandidates[0];
|
||||
// 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 = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
|
||||
const columns = this.allocateColumns(gridCandidates);
|
||||
|
||||
|
|
@ -201,6 +161,78 @@ export class EventLayoutCoordinator {
|
|||
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
|
||||
*/
|
||||
private detectConflict(event1: CalendarEvent, event2: CalendarEvent, thresholdMinutes: number): boolean {
|
||||
// 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
|
||||
*/
|
||||
private expandGridCandidates(
|
||||
firstEvent: CalendarEvent,
|
||||
remaining: CalendarEvent[],
|
||||
thresholdMinutes: number
|
||||
): CalendarEvent[] {
|
||||
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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export class DateEventRenderer implements EventRendererStrategy {
|
|||
|
||||
// Delegate to SwpEventElement to update position and timestamps
|
||||
const swpEvent = this.draggedClone as SwpEventElement;
|
||||
const columnDate = new Date(payload.columnBounds.date);
|
||||
const columnDate = this.dateService.parseISO(payload.columnBounds.date);
|
||||
swpEvent.updatePosition(columnDate, payload.snappedY);
|
||||
}
|
||||
|
||||
|
|
@ -111,11 +111,11 @@ export class DateEventRenderer implements EventRendererStrategy {
|
|||
const eventsLayer = dragColumnChangeEvent.newColumn.element.querySelector('swp-events-layer');
|
||||
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
|
||||
eventsLayer.appendChild(this.draggedClone);
|
||||
|
||||
|
||||
// Recalculate timestamps with new column date
|
||||
const currentTop = parseFloat(this.draggedClone.style.top) || 0;
|
||||
const swpEvent = this.draggedClone as SwpEventElement;
|
||||
const columnDate = new Date(dragColumnChangeEvent.newColumn.date);
|
||||
const columnDate = this.dateService.parseISO(dragColumnChangeEvent.newColumn.date);
|
||||
swpEvent.updatePosition(columnDate, currentTop);
|
||||
}
|
||||
}
|
||||
|
|
@ -221,16 +221,15 @@ export class DateEventRenderer implements EventRendererStrategy {
|
|||
// Position from layout
|
||||
groupElement.style.top = `${gridGroup.position.top}px`;
|
||||
|
||||
// Add inline styles for margin-left and z-index (guaranteed to work)
|
||||
groupElement.style.marginLeft = `${gridGroup.stackLevel * 15}px`;
|
||||
groupElement.style.zIndex = `${this.stackManager.calculateZIndex(gridGroup.stackLevel)}`;
|
||||
|
||||
// Add stack-link attribute for drag-drop (group acts as a stacked item)
|
||||
const stackLink = {
|
||||
stackLevel: gridGroup.stackLevel
|
||||
};
|
||||
this.stackManager.applyStackLinkToElement(groupElement, stackLink);
|
||||
|
||||
// Apply visual styling (margin-left and z-index) using StackManager
|
||||
this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel);
|
||||
|
||||
// Render each column
|
||||
const earliestEvent = gridGroup.events[0];
|
||||
gridGroup.columns.forEach(columnEvents => {
|
||||
|
|
@ -330,11 +329,14 @@ export class DateEventRenderer implements EventRendererStrategy {
|
|||
return [];
|
||||
}
|
||||
|
||||
const columnEvents = events.filter(event => {
|
||||
const eventDateStr = this.dateService.formatISODate(event.start);
|
||||
const matches = eventDateStr === columnDate;
|
||||
// Create start and end of day for interval overlap check
|
||||
const columnStart = this.dateService.parseISO(`${columnDate}T00:00:00`);
|
||||
const columnEnd = this.dateService.parseISO(`${columnDate}T23:59:59.999`);
|
||||
|
||||
return matches;
|
||||
const columnEvents = events.filter(event => {
|
||||
// Interval overlap: event overlaps with column day if event.start < columnEnd AND event.end > columnStart
|
||||
const overlaps = event.start < columnEnd && event.end > columnStart;
|
||||
return overlaps;
|
||||
});
|
||||
|
||||
return columnEvents;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue