Stacking and Sharecolumn WIP
This commit is contained in:
parent
c788a1695e
commit
6b8c5d4673
7 changed files with 763 additions and 51 deletions
|
|
@ -13,6 +13,7 @@ export interface GridGroupLayout {
|
|||
events: CalendarEvent[];
|
||||
stackLevel: number;
|
||||
position: { top: number };
|
||||
columns: CalendarEvent[][]; // Events grouped by column (events in same array share a column)
|
||||
}
|
||||
|
||||
export interface StackedEventLayout {
|
||||
|
|
@ -60,11 +61,13 @@ export class EventLayoutCoordinator {
|
|||
const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks);
|
||||
const earliestEvent = group.events[0];
|
||||
const position = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
|
||||
const columns = this.allocateColumns(group.events);
|
||||
|
||||
gridGroupLayouts.push({
|
||||
events: group.events,
|
||||
stackLevel: gridStackLevel,
|
||||
position: { top: position.top + 1 }
|
||||
position: { top: position.top + 1 },
|
||||
columns
|
||||
});
|
||||
|
||||
group.events.forEach(e => renderedEventIds.add(e.id));
|
||||
|
|
@ -119,4 +122,45 @@ export class EventLayoutCoordinator {
|
|||
// Grid group should be one level above the highest overlapping event
|
||||
return maxOverlappingLevel + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private allocateColumns(events: CalendarEvent[]): CalendarEvent[][] {
|
||||
if (events.length === 0) return [];
|
||||
if (events.length === 1) return [[events[0]]];
|
||||
|
||||
const columns: CalendarEvent[][] = [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,17 @@
|
|||
* 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)
|
||||
* Implements 3-phase algorithm for grid + nested stacking:
|
||||
* Phase 1: Group events by start time proximity (configurable threshold)
|
||||
* Phase 2: Decide container type (GRID vs STACKING)
|
||||
* Phase 3: Handle late arrivals (nested stacking - NOT IMPLEMENTED)
|
||||
*
|
||||
* @see STACKING_CONCEPT.md for detailed documentation
|
||||
* @see stacking-visualization.html for visual examples
|
||||
*/
|
||||
|
||||
import { CalendarEvent } from '../types/CalendarTypes';
|
||||
import { calendarConfig } from '../core/CalendarConfig';
|
||||
|
||||
export interface StackLink {
|
||||
prev?: string; // Event ID of previous event in stack
|
||||
|
|
@ -28,7 +29,6 @@ export interface EventGroup {
|
|||
}
|
||||
|
||||
export class EventStackManager {
|
||||
private static readonly FLEXBOX_START_THRESHOLD_MINUTES = 15;
|
||||
private static readonly STACK_OFFSET_PX = 15;
|
||||
|
||||
// ============================================
|
||||
|
|
@ -36,22 +36,49 @@ export class EventStackManager {
|
|||
// ============================================
|
||||
|
||||
/**
|
||||
* Group events by start time proximity (±15 min threshold)
|
||||
* Group events by time conflicts (both start-to-start and end-to-start within threshold)
|
||||
*
|
||||
* Events are grouped if:
|
||||
* 1. They start within ±threshold minutes of each other (start-to-start)
|
||||
* 2. One event starts within threshold minutes before another ends (end-to-start conflict)
|
||||
*/
|
||||
public groupEventsByStartTime(events: CalendarEvent[]): EventGroup[] {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
// Get threshold from config
|
||||
const gridSettings = calendarConfig.getGridSettings();
|
||||
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
||||
|
||||
// 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
|
||||
// Find existing group that this event conflicts with
|
||||
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;
|
||||
// Check if event conflicts with ANY event in the group
|
||||
return group.events.some(groupEvent => {
|
||||
// Start-to-start conflict: events start within threshold
|
||||
const startToStartMinutes = Math.abs(event.start.getTime() - groupEvent.start.getTime()) / (1000 * 60);
|
||||
if (startToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// End-to-start conflict: event starts within threshold before groupEvent ends
|
||||
const endToStartMinutes = (groupEvent.end.getTime() - event.start.getTime()) / (1000 * 60);
|
||||
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check reverse: groupEvent starts within threshold before event ends
|
||||
const reverseEndToStart = (event.end.getTime() - groupEvent.start.getTime()) / (1000 * 60);
|
||||
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
if (existingGroup) {
|
||||
|
|
@ -76,7 +103,7 @@ export class EventStackManager {
|
|||
/**
|
||||
* Decide container type for a group of events
|
||||
*
|
||||
* Rule: Events starting simultaneously (within ±15 min) should ALWAYS use GRID,
|
||||
* Rule: Events starting simultaneously (within threshold) should ALWAYS use GRID,
|
||||
* even if they overlap each other. This provides better visual indication that
|
||||
* events start at the same time.
|
||||
*/
|
||||
|
|
@ -85,7 +112,7 @@ export class EventStackManager {
|
|||
return 'NONE';
|
||||
}
|
||||
|
||||
// If events are grouped together (start within ±15 min), they should share columns (GRID)
|
||||
// If events are grouped together (start within threshold), 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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue