Stacking and Sharecolumn WIP

This commit is contained in:
Janus C. H. Knudsen 2025-10-06 17:05:18 +02:00
parent c788a1695e
commit 6b8c5d4673
7 changed files with 763 additions and 51 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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;
}