Calendar/packages/calendar/src/features/event/EventLayoutEngine.ts

280 lines
8.8 KiB
TypeScript
Raw Normal View History

/**
* EventLayoutEngine - Simplified stacking/grouping algorithm
*
* Supports two layout modes:
* - GRID: Events starting at same time rendered side-by-side
* - STACKING: Overlapping events with margin-left offset (15px per level)
*
* No prev/next chains, single-pass greedy algorithm
*/
import { ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition } from '../../utils/PositionUtils';
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
/**
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
* This matches Scenario 8: end===start is NOT overlap
*/
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
return a.start < b.end && a.end > b.start;
}
/**
* Check if two events are within threshold for grid grouping.
* This includes:
* 1. Start-to-start: Events start within threshold of each other
* 2. End-to-start: One event starts within threshold before another ends
*/
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
const thresholdMs = thresholdMinutes * 60 * 1000;
// Start-to-start: both events start within threshold
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
if (startToStartDiff <= thresholdMs) return true;
// End-to-start: one event starts within threshold before the other ends
// B starts within threshold before A ends
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
// A starts within threshold before B ends
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
return false;
}
/**
* Check if all events in a group start within threshold of each other
*/
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
if (events.length <= 1) return true;
// Find earliest and latest start times
let earliest = events[0].start.getTime();
let latest = events[0].start.getTime();
for (const event of events) {
const time = event.start.getTime();
if (time < earliest) earliest = time;
if (time > latest) latest = time;
}
const diffMinutes = (latest - earliest) / (1000 * 60);
return diffMinutes <= thresholdMinutes;
}
/**
* Find groups of overlapping events (connected by overlap chain)
* Events are grouped if they overlap with any event in the group
*/
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
// Start a new group with this event
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand group by finding all connected events (via overlap)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
// Check if candidate overlaps with any event in group
const connects = group.some(member => eventsOverlap(member, candidate));
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Find grid candidates within a group - events connected via threshold chain
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
*/
function findGridCandidates(
events: ICalendarEvent[],
thresholdMinutes: number
): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
const connects = group.some(member =>
eventsWithinThreshold(member, candidate, thresholdMinutes)
);
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Calculate stack levels for overlapping events using greedy algorithm
* For each event: level = max(overlapping already-processed events) + 1
*/
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
const levels = new Map<string, number>();
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
for (const event of sorted) {
let maxOverlappingLevel = -1;
// Find max level among overlapping events already processed
for (const [id, level] of levels) {
const other = events.find(e => e.id === id);
if (other && eventsOverlap(event, other)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
}
}
levels.set(event.id, maxOverlappingLevel + 1);
}
return levels;
}
/**
* Allocate events to columns for GRID layout using greedy algorithm
* Non-overlapping events can share a column to minimize total columns
*/
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const columns: ICalendarEvent[][] = [];
for (const event of sorted) {
// Find first column where event doesn't overlap with existing events
let placed = false;
for (const column of columns) {
const canFit = !column.some(e => eventsOverlap(event, e));
if (canFit) {
column.push(event);
placed = true;
break;
}
}
// No suitable column found, create new one
if (!placed) {
columns.push([event]);
}
}
return columns;
}
/**
* Main entry point: Calculate complete layout for a column's events
*
* Algorithm:
* 1. Find overlap groups (events connected by overlap chain)
* 2. For each overlap group, find grid candidates (events within threshold chain)
* 3. If all events in overlap group form a single grid candidate GRID mode
* 4. Otherwise STACKING mode with calculated levels
*/
export function calculateColumnLayout(
events: ICalendarEvent[],
config: IGridConfig
): IColumnLayout {
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
const result: IColumnLayout = {
grids: [],
stacked: []
};
if (events.length === 0) return result;
// Find all overlapping event groups
const overlapGroups = findOverlapGroups(events);
for (const overlapGroup of overlapGroups) {
if (overlapGroup.length === 1) {
// Single event - no grouping needed
result.stacked.push({
event: overlapGroup[0],
stackLevel: 0
});
continue;
}
// Within this overlap group, find grid candidates (threshold-connected subgroups)
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
// Check if the ENTIRE overlap group forms a single grid candidate
// This happens when all events are connected via threshold chain
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
g.length > max.length ? g : max, gridSubgroups[0]);
if (largestGridCandidate.length === overlapGroup.length) {
// All events in overlap group are connected via threshold chain → GRID mode
const columns = allocateColumns(overlapGroup);
const earliest = overlapGroup.reduce((min, e) =>
e.start < min.start ? e : min, overlapGroup[0]);
const position = calculateEventPosition(earliest.start, earliest.end, config);
result.grids.push({
events: overlapGroup,
columns,
stackLevel: 0,
position: { top: position.top }
});
} else {
// Not all events connected via threshold → STACKING mode
const levels = calculateStackLevels(overlapGroup);
for (const event of overlapGroup) {
result.stacked.push({
event,
stackLevel: levels.get(event.id) ?? 0
});
}
}
}
return result;
}