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! + +
+

Scenario 8: Edge Case - Events Starting Exactly 15 Minutes Apart (WITH Overlap)

+

Edge Case: What happens when events start exactly at the ±15 min threshold AND overlap?

+ +

Events:

+ + +
+ Analysis:
+ • A starts at 11:00
+ • B starts at 11:15 (diff = 15 min ≤ 15 min) → Within threshold
+ • A and B overlap (11:15 - 12:00) → They DO overlap
+ • Visual priority: Show that they start simultaneously (±15 min)
+ • Result: Use GRID (column sharing) even though they overlap +
+ +
+ +
+
❌ Wrong: Stacking (Hides Simultaneity)
+ +
+
11:00
+
11:30
+
12:00
+
12:30
+
+ +
+ +
+ Event A
+ 11:00-12:00 +
+ + +
+ Event B
+ 11:15-12:30 +
+
+ +
+ Problems:
+ • B is offset to the right → looks like it happens AFTER A
+ • Doesn't convey that they start almost simultaneously (15 min apart)
+ • Wastes horizontal space +
+
+ + +
+
✅ Correct: GRID Column Sharing
+ +
+
11:00
+
11:30
+
12:00
+
12:30
+
+ +
+ +
+ +
+ Event A
+ 11:00-12:00 +
+ + +
+ Event B
+ 11:15-12:30 +
+
+
+ +
+ Benefits:
+ • Side-by-side layout shows they're concurrent
+ • Each event gets 50% width
+ • Clear visual: these events start nearly simultaneously (±15 min)
+ • Despite overlapping, simultaneity is visual priority +
+
+
+ +
+ Key Rule: Events starting within ±15 minutes should ALWAYS use GRID (column sharing), + even if they overlap. The visual priority is to show that events start simultaneously, + not to avoid overlap. Overlap is handled by the grid container having appropriate height. +
+ +
+ Expected Behavior: +
+// 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>
+
+
+ + + + +
+

Scenario 9: Grid with Staggered Start Times

+ +
+
Event A: 09:00 - 10:00 (1 hour)
+
Event B: 09:30 - 10:30 (1 hour, starts 30 min after A)
+
Event C: 10:15 - 12:00 (1h 45min, starts 45 min after B)
+
+ +
+ Special Case: End-to-Start Conflicts Create Shared Columns

+ • Event A: 09:00 - 10:00
+ • Event B: 09:30 - 10:30 (starts 30 min before A ends → conflicts with A)
+ • Event C: 10:15 - 12:00 (starts 15 min before B ends → conflicts with B)

+ Key Rule: Events share columns (GRID) when they conflict within threshold
+ • Conflict = Event starts within ±threshold minutes of another event's end time
+ • A and B: B starts 30 min before A ends → conflict (≤ 30 min threshold)
+ • B and C: C starts 15 min before B ends → conflict (≤ 30 min threshold)
+ • Therefore: A, B, and C all share columns in a 3-column GRID

+ With threshold = 15 min: Only A-B conflict (30 min > 15), C is separate → Stack
+ With threshold = 30 min: Both A-B and B-C conflict → All 3 share columns in GRID +
+ +
+ +
+
With Threshold = 15 min
+
+
09:00
+
10:00
+
11:00
+
12:00
+
+ +
+ +
+ Event A
09:00-10:00 +
+ + +
+ Event B
09:30-10:30 +
+ + +
+ Event C
10:15-12:00 +
+
+
+ + +
+
With Threshold = 30 min
+
+
09:00
+
10:00
+
11:00
+
12:00
+
+ +
+ +
+ +
+
+ Event A
09:00-10:00 +
+
+ Event C
10:15-12:00 +
+
+ + +
+
+ Event B
09:30-10:30 +
+
+
+
+
+
+ +

Stack Analysis

+
+

Threshold = 15 min (Stack):

+ + +

Threshold = 30 min (Shared GRID with 2 columns):

+ +
+
+ + + + +
+

Scenario 10: Complex Column Sharing

+ +
+
Event A: 12:00 - 15:00 (3 hours)
+
Event B: 12:30 - 13:00 (30 min, starts 30 min after A)
+
Event C: 13:30 - 14:30 (1 hour, starts 30 min after B ends)
+
Event D: 14:00 - 15:00 (1 hour, starts 30 min before C ends)
+
Event E: 14:00 - 15:00 (1 hour, starts same time as D)
+
+ +
+ Analysis with threshold = 30 min:
+ • A-B conflict: B starts 30 min after A (≤ 30) → grouped
+ • B-C conflict: C starts 30 min after B ends (≤ 30) → grouped with A-B
+ • C-D conflict: D starts 30 min before C ends (≤ 30) → grouped with A-B-C
+ • D-E conflict: D and E start at same time (0 min) → grouped with all
+ • Therefore: All 5 events in ONE grid group

+ Column allocation:
+ • A overlaps: B, C, D, E → needs own column
+ • B overlaps: A → needs own column
+ • C overlaps: A, D, E → needs own column
+ • D overlaps: A, C, E → needs own column
+ • E overlaps: A, C, D → can share column with B (they don't overlap)
+ • Result: 4 columns needed +
+ +
+ +
+
With Threshold = 15 min
+
+
12:00
+
13:00
+
14:00
+
15:00
+
+ +
+ +
+ Event A
12:00-15:00 +
+ + +
+ Event B
12:30-13:00 +
+ + +
+ Event C
13:30-14:30 +
+ + +
+
+
+ Event D
14:00-15:00 +
+
+
+
+ Event E
14:00-15:00 +
+
+
+
+
+ + +
+
With Threshold = 30 min
+
+
12:00
+
13:00
+
14:00
+
15:00
+
+ +
+ +
+ + +
+
+ Event A
12:00-15:00 +
+
+ + +
+
+ Event B
12:30-13:00 +
+
+ Event E
14:00-15:00 +
+
+ + +
+
+ Event C
13:30-14:30 +
+
+ + +
+
+ Event D
14:00-15:00 +
+
+
+
+
+
+ +

Expected Layout

+
+

Threshold = 15 min (Stack + Small Grid):

+ + +

Threshold = 30 min (Large Grid):

+ + +

Key Points:

+ +
+
+