diff --git a/src/core/CalendarConfig.ts b/src/core/CalendarConfig.ts index 2602558..cb731fc 100644 --- a/src/core/CalendarConfig.ts +++ b/src/core/CalendarConfig.ts @@ -33,7 +33,10 @@ interface GridSettings { snapInterval: number; fitToWidth: boolean; scrollToHour: number | null; - + + // Event grouping settings + gridStartThresholdMinutes: number; // ±N minutes for events to share grid columns + // Display options showCurrentTime: boolean; showWorkHours: boolean; @@ -132,6 +135,7 @@ export class CalendarConfig { workStartHour: 8, workEndHour: 17, snapInterval: 15, + gridStartThresholdMinutes: 30, // Events starting within ±15 min share grid columns showCurrentTime: true, showWorkHours: true, fitToWidth: false, diff --git a/src/data/mock-events.json b/src/data/mock-events.json index 477028d..7a2f5d6 100644 --- a/src/data/mock-events.json +++ b/src/data/mock-events.json @@ -1922,6 +1922,18 @@ "duration": 120, "color": "#f44336" } + },{ + "id": "1481", + "title": "Kvartal Afslutning 2", + "start": "2025-09-30T11:20:00Z", + "end": "2025-09-30T13:00:00Z", + "type": "milestone", + "allDay": false, + "syncStatus": "synced", + "metadata": { + "duration": 120, + "color": "#f44336" + } }, { "id": "149", diff --git a/src/managers/EventLayoutCoordinator.ts b/src/managers/EventLayoutCoordinator.ts index 1553909..f817612 100644 --- a/src/managers/EventLayoutCoordinator.ts +++ b/src/managers/EventLayoutCoordinator.ts @@ -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; + } } diff --git a/src/managers/EventStackManager.ts b/src/managers/EventStackManager.ts index 3499ae7..09a0e7c 100644 --- a/src/managers/EventStackManager.ts +++ b/src/managers/EventStackManager.ts @@ -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'; diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index 640c99b..9a93c50 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -206,13 +206,13 @@ export class DateEventRenderer implements EventRendererStrategy { }); } /** - * Render events in a grid container (side-by-side) + * Render events in a grid container (side-by-side with column sharing) */ private renderGridGroup(gridGroup: GridGroupLayout, eventsLayer: HTMLElement): void { const groupElement = document.createElement('swp-event-group'); - // Add grid column class based on event count - const colCount = gridGroup.events.length; + // Add grid column class based on number of columns (not events) + const colCount = gridGroup.columns.length; groupElement.classList.add(`cols-${colCount}`); // Add stack level class for margin-left offset @@ -231,18 +231,34 @@ export class DateEventRenderer implements EventRendererStrategy { }; this.stackManager.applyStackLinkToElement(groupElement, stackLink); - // Render each event within the grid + // Render each column const earliestEvent = gridGroup.events[0]; - gridGroup.events.forEach(event => { - const element = this.renderEventInGrid(event, earliestEvent.start); - groupElement.appendChild(element); + gridGroup.columns.forEach(columnEvents => { + const columnContainer = this.renderGridColumn(columnEvents, earliestEvent.start); + groupElement.appendChild(columnContainer); }); eventsLayer.appendChild(groupElement); } /** - * Render event within a grid container (relative positioning) + * Render a single column within a grid group + * Column may contain multiple events that don't overlap + */ + private renderGridColumn(columnEvents: CalendarEvent[], containerStart: Date): HTMLElement { + const columnContainer = document.createElement('div'); + columnContainer.style.position = 'relative'; + + columnEvents.forEach(event => { + const element = this.renderEventInGrid(event, containerStart); + columnContainer.appendChild(element); + }); + + return columnContainer; + } + + /** + * Render event within a grid container (absolute positioning within column) */ private renderEventInGrid(event: CalendarEvent, containerStart: Date): HTMLElement { const element = SwpEventElement.fromCalendarEvent(event); @@ -250,10 +266,19 @@ export class DateEventRenderer implements EventRendererStrategy { // Calculate event height const position = this.calculateEventPosition(event); - // Events in grid are positioned relatively - NO top offset needed - // The grid container itself is positioned absolutely with the correct top - element.style.position = 'relative'; + // Calculate relative top offset if event starts after container start + // (e.g., if container starts at 07:00 and event starts at 08:15, offset = 75 min) + const timeDiffMs = event.start.getTime() - containerStart.getTime(); + const timeDiffMinutes = timeDiffMs / (1000 * 60); + const gridSettings = calendarConfig.getGridSettings(); + const relativeTop = timeDiffMinutes > 0 ? (timeDiffMinutes / 60) * gridSettings.hourHeight : 0; + + // Events in grid columns are positioned absolutely within their column container + element.style.position = 'absolute'; + element.style.top = `${relativeTop}px`; element.style.height = `${position.height - 3}px`; + element.style.left = '0'; + element.style.right = '0'; return element; } diff --git a/stacking-visualization.html b/stacking-visualization.html index 2fb973d..1f00834 100644 --- a/stacking-visualization.html +++ b/stacking-visualization.html @@ -1415,6 +1415,397 @@ Result: One algorithm handles ALL scenarios! + +
Edge Case: What happens when events start exactly at the ±15 min threshold AND overlap?
+ +Events:
++// Phase 1: Group by start time +groupEventsByStartTime([A, B]) + → Group 1: [A, B] // 15 min apart ≤ threshold + +// Phase 2: Decide container type +decideContainerType(Group 1) + → GRID // Always GRID for grouped events, even if overlapping + +// Phase 3: Calculate stack level +calculateGridGroupStackLevel(Group 1) + → stackLevel: 0 // No other events to stack above + +// Result: +<swp-event-group class="cols-2 stack-level-0" style="top: 0px; margin-left: 0px; z-index: 100;"> + <swp-event data-event-id="A" style="height: 120px;">Event A</swp-event> + <swp-event data-event-id="B" style="height: 150px; top: 10%;">Event B</swp-event> +</swp-event-group>+
Threshold = 15 min (Stack):
+Threshold = 30 min (Shared GRID with 2 columns):
+Threshold = 15 min (Stack + Small Grid):
+Threshold = 30 min (Large Grid):
+Key Points:
+