280 lines
8.8 KiB
TypeScript
280 lines
8.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|