Extracts event layout calculations

Moves complex layout determination logic (grid grouping, stack levels, positioning) from `EventRenderer` to a new `EventLayoutCoordinator` class.

Delegates layout responsibilities to the coordinator, significantly simplifying the `EventRenderer`'s `renderColumnEvents` method. Refines `EventStackManager` by removing deprecated layout methods, consolidating its role to event grouping and core stack level management.

Improves modularity and separation of concerns within the rendering pipeline.
This commit is contained in:
Janus C. H. Knudsen 2025-10-06 00:24:13 +02:00
parent 2f58ceccd4
commit c788a1695e
5 changed files with 166 additions and 248 deletions

View file

@ -0,0 +1,122 @@
/**
* EventLayoutCoordinator - Coordinates event layout calculations
*
* Separates layout logic from rendering concerns.
* Calculates stack levels, groups events, and determines rendering strategy.
*/
import { CalendarEvent } from '../types/CalendarTypes';
import { EventStackManager, EventGroup, StackLink } from './EventStackManager';
import { PositionUtils } from '../utils/PositionUtils';
export interface GridGroupLayout {
events: CalendarEvent[];
stackLevel: number;
position: { top: number };
}
export interface StackedEventLayout {
event: CalendarEvent;
stackLink: StackLink;
position: { top: number; height: number };
}
export interface ColumnLayout {
gridGroups: GridGroupLayout[];
stackedEvents: StackedEventLayout[];
}
export class EventLayoutCoordinator {
private stackManager: EventStackManager;
constructor() {
this.stackManager = new EventStackManager();
}
/**
* Calculate complete layout for a column of events
*/
public calculateColumnLayout(columnEvents: CalendarEvent[]): ColumnLayout {
if (columnEvents.length === 0) {
return { gridGroups: [], stackedEvents: [] };
}
// Step 1: Calculate stack levels for ALL events first (to understand overlaps)
const allStackLinks = this.stackManager.createOptimizedStackLinks(columnEvents);
// Step 2: Find grid candidates (start together ±15 min)
const groups = this.stackManager.groupEventsByStartTime(columnEvents);
const gridGroups = groups.filter(group => {
if (group.events.length <= 1) return false;
group.containerType = this.stackManager.decideContainerType(group);
return group.containerType === 'GRID';
});
// Step 3: Build grid group layouts
const gridGroupLayouts: GridGroupLayout[] = [];
const renderedEventIds = new Set<string>();
gridGroups.forEach(group => {
const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks);
const earliestEvent = group.events[0];
const position = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
gridGroupLayouts.push({
events: group.events,
stackLevel: gridStackLevel,
position: { top: position.top + 1 }
});
group.events.forEach(e => renderedEventIds.add(e.id));
});
// Step 4: Build stacked event layouts for remaining events
const remainingEvents = columnEvents.filter(e => !renderedEventIds.has(e.id));
const stackedEventLayouts: StackedEventLayout[] = remainingEvents.map(event => {
const stackLink = allStackLinks.get(event.id)!;
const position = PositionUtils.calculateEventPosition(event.start, event.end);
return {
event,
stackLink,
position: { top: position.top + 1, height: position.height - 3 }
};
});
return {
gridGroups: gridGroupLayouts,
stackedEvents: stackedEventLayouts
};
}
/**
* Calculate stack level for a grid group based on what it overlaps OUTSIDE the group
*/
private calculateGridGroupStackLevel(
group: EventGroup,
allEvents: CalendarEvent[],
stackLinks: Map<string, StackLink>
): number {
const groupEventIds = new Set(group.events.map(e => e.id));
// Find all events OUTSIDE this group
const outsideEvents = allEvents.filter(e => !groupEventIds.has(e.id));
// Find the highest stackLevel of any event that overlaps with ANY event in the grid group
let maxOverlappingLevel = -1;
for (const gridEvent of group.events) {
for (const outsideEvent of outsideEvents) {
if (this.stackManager.doEventsOverlap(gridEvent, outsideEvent)) {
const outsideLink = stackLinks.get(outsideEvent.id);
if (outsideLink) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, outsideLink.stackLevel);
}
}
}
}
// Grid group should be one level above the highest overlapping event
return maxOverlappingLevel + 1;
}
}

View file

@ -68,13 +68,6 @@ export class EventStackManager {
return groups; return groups;
} }
/**
* Check if two events should share flexbox (within ±15 min)
*/
public shouldShareFlexbox(event1: CalendarEvent, event2: CalendarEvent): boolean {
const diffMinutes = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60);
return diffMinutes <= EventStackManager.FLEXBOX_START_THRESHOLD_MINUTES;
}
// ============================================ // ============================================
// PHASE 2: Container Type Decision // PHASE 2: Container Type Decision
@ -98,19 +91,6 @@ export class EventStackManager {
return 'GRID'; return 'GRID';
} }
/**
* Check if events within a group overlap each other
*/
private hasInternalOverlaps(events: CalendarEvent[]): boolean {
for (let i = 0; i < events.length; i++) {
for (let j = i + 1; j < events.length; j++) {
if (this.doEventsOverlap(events[i], events[j])) {
return true;
}
}
}
return false;
}
/** /**
* Check if two events overlap in time * Check if two events overlap in time
@ -119,117 +99,11 @@ export class EventStackManager {
return event1.start < event2.end && event1.end > event2.start; return event1.start < event2.end && event1.end > event2.start;
} }
// ============================================
// PHASE 3: Late Arrivals (Nested Stacking)
// ============================================
/**
* Find events that start outside threshold (late arrivals)
*/
public findLateArrivals(groups: EventGroup[], allEvents: CalendarEvent[]): CalendarEvent[] {
const eventsInGroups = new Set(groups.flatMap(g => g.events.map(e => e.id)));
return allEvents.filter(event => !eventsInGroups.has(event.id));
}
/**
* Find primary parent column for a late event (longest duration or first overlapping)
*/
public findPrimaryParentColumn(lateEvent: CalendarEvent, flexboxGroup: CalendarEvent[]): string | null {
// Find all overlapping events in the flexbox group
const overlapping = flexboxGroup.filter(event => this.doEventsOverlap(lateEvent, event));
if (overlapping.length === 0) {
return null;
}
// Sort by duration (longest first)
overlapping.sort((a, b) => {
const durationA = b.end.getTime() - b.start.getTime();
const durationB = a.end.getTime() - a.start.getTime();
return durationA - durationB;
});
return overlapping[0].id;
}
/**
* Calculate marginLeft for nested event (always 15px)
*/
public calculateNestedMarginLeft(): number {
return EventStackManager.STACK_OFFSET_PX;
}
/**
* Calculate stackLevel for nested event (parent + 1)
*/
public calculateNestedStackLevel(parentStackLevel: number): number {
return parentStackLevel + 1;
}
// ============================================ // ============================================
// Flexbox Layout Calculations // Stack Level Calculation
// ============================================ // ============================================
/**
* Calculate flex width for flexbox columns
*/
public calculateFlexWidth(columnCount: number): string {
if (columnCount === 1) return '100%';
if (columnCount === 2) return '50%';
if (columnCount === 3) return '33.33%';
if (columnCount === 4) return '25%';
// For 5+ columns, calculate percentage
const percentage = (100 / columnCount).toFixed(2);
return `${percentage}%`;
}
// ============================================
// Existing Methods (from original TDD tests)
// ============================================
/**
* Find all events that overlap with a given event
*/
public findOverlappingEvents(targetEvent: CalendarEvent, columnEvents: CalendarEvent[]): CalendarEvent[] {
return columnEvents.filter(event => this.doEventsOverlap(targetEvent, event));
}
/**
* Create stack links for overlapping events (naive sequential stacking)
*/
public createStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
const stackLinks = new Map<string, StackLink>();
if (events.length === 0) return stackLinks;
// Sort by start time (and by end time if start times are equal)
const sorted = [...events].sort((a, b) => {
const startDiff = a.start.getTime() - b.start.getTime();
if (startDiff !== 0) return startDiff;
return a.end.getTime() - b.end.getTime();
});
// Create sequential stack
sorted.forEach((event, index) => {
const link: StackLink = {
stackLevel: index
};
if (index > 0) {
link.prev = sorted[index - 1].id;
}
if (index < sorted.length - 1) {
link.next = sorted[index + 1].id;
}
stackLinks.set(event.id, link);
});
return stackLinks;
}
/** /**
* Create optimized stack links (events share levels when possible) * Create optimized stack links (events share levels when possible)
*/ */
@ -248,20 +122,16 @@ export class EventStackManager {
other !== event && this.doEventsOverlap(event, other) other !== event && this.doEventsOverlap(event, other)
); );
console.log(`[EventStackManager] Event ${event.id} overlaps with:`, overlapping.map(e => e.id));
// Find the MINIMUM required level (must be above all overlapping events) // Find the MINIMUM required level (must be above all overlapping events)
let minRequiredLevel = 0; let minRequiredLevel = 0;
for (const other of overlapping) { for (const other of overlapping) {
const otherLink = stackLinks.get(other.id); const otherLink = stackLinks.get(other.id);
if (otherLink) { if (otherLink) {
console.log(` ${other.id} has stackLevel ${otherLink.stackLevel}`);
// Must be at least one level above the overlapping event // Must be at least one level above the overlapping event
minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1); minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1);
} }
} }
console.log(` → Assigned stackLevel ${minRequiredLevel} (must be above all overlapping events)`);
stackLinks.set(event.id, { stackLevel: minRequiredLevel }); stackLinks.set(event.id, { stackLevel: minRequiredLevel });
} }

View file

@ -7,7 +7,8 @@ import { PositionUtils } from '../utils/PositionUtils';
import { ColumnBounds } from '../utils/ColumnDetectionUtils'; import { ColumnBounds } from '../utils/ColumnDetectionUtils';
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes'; import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService'; import { DateService } from '../utils/DateService';
import { EventStackManager, EventGroup, StackLink } from '../managers/EventStackManager'; import { EventStackManager } from '../managers/EventStackManager';
import { EventLayoutCoordinator, GridGroupLayout, StackedEventLayout } from '../managers/EventLayoutCoordinator';
/** /**
* Interface for event rendering strategies * Interface for event rendering strategies
@ -31,6 +32,7 @@ export class DateEventRenderer implements EventRendererStrategy {
private dateService: DateService; private dateService: DateService;
private stackManager: EventStackManager; private stackManager: EventStackManager;
private layoutCoordinator: EventLayoutCoordinator;
private draggedClone: HTMLElement | null = null; private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null; private originalEvent: HTMLElement | null = null;
@ -38,6 +40,7 @@ export class DateEventRenderer implements EventRendererStrategy {
const timezone = calendarConfig.getTimezone?.(); const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone); this.dateService = new DateService(timezone);
this.stackManager = new EventStackManager(); this.stackManager = new EventStackManager();
this.layoutCoordinator = new EventLayoutCoordinator();
} }
private applyDragStyling(element: HTMLElement): void { private applyDragStyling(element: HTMLElement): void {
@ -186,138 +189,51 @@ export class DateEventRenderer implements EventRendererStrategy {
private renderColumnEvents(columnEvents: CalendarEvent[], eventsLayer: HTMLElement): void { private renderColumnEvents(columnEvents: CalendarEvent[], eventsLayer: HTMLElement): void {
if (columnEvents.length === 0) return; if (columnEvents.length === 0) return;
console.log('[EventRenderer] Rendering column with', columnEvents.length, 'events'); // Get layout from coordinator
const layout = this.layoutCoordinator.calculateColumnLayout(columnEvents);
// Step 1: Calculate stack levels for ALL events first (to understand overlaps) // Render grid groups
const allStackLinks = this.stackManager.createOptimizedStackLinks(columnEvents); layout.gridGroups.forEach(gridGroup => {
this.renderGridGroup(gridGroup, eventsLayer);
console.log('[EventRenderer] All stack links:');
columnEvents.forEach(event => {
const link = allStackLinks.get(event.id);
console.log(` Event ${event.id} (${event.title}): stackLevel=${link?.stackLevel ?? 'none'}`);
}); });
// Step 2: Find grid candidates (start together ±15 min) // Render stacked events
const groups = this.stackManager.groupEventsByStartTime(columnEvents); layout.stackedEvents.forEach(stackedEvent => {
const gridGroups = groups.filter(group => { const element = this.renderEvent(stackedEvent.event);
if (group.events.length <= 1) return false; this.stackManager.applyStackLinkToElement(element, stackedEvent.stackLink);
group.containerType = this.stackManager.decideContainerType(group); this.stackManager.applyVisualStyling(element, stackedEvent.stackLink.stackLevel);
return group.containerType === 'GRID';
});
console.log('[EventRenderer] Grid groups:', gridGroups.length);
gridGroups.forEach((g, i) => {
console.log(` Grid group ${i}:`, g.events.map(e => e.id));
});
// Step 3: Render grid groups and track which events have been rendered
const renderedIds = new Set<string>();
gridGroups.forEach((group, index) => {
console.log(`[EventRenderer] Rendering grid group ${index} with ${group.events.length} events:`, group.events.map(e => e.id));
// Calculate grid group stack level by finding what it overlaps OUTSIDE the group
const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks);
console.log(` Grid group stack level: ${gridStackLevel}`);
this.renderGridGroup(group, eventsLayer, gridStackLevel);
group.events.forEach(e => renderedIds.add(e.id));
});
// Step 4: Get remaining events (not in grid)
const remainingEvents = columnEvents.filter(e => !renderedIds.has(e.id));
console.log('[EventRenderer] Remaining events for stacking:');
remainingEvents.forEach(event => {
const link = allStackLinks.get(event.id);
console.log(` Event ${event.id} (${event.title}): stackLevel=${link?.stackLevel ?? 'none'}`);
});
// Step 5: Render remaining stacked/single events
remainingEvents.forEach(event => {
const element = this.renderEvent(event);
const stackLink = allStackLinks.get(event.id);
console.log(`[EventRenderer] Rendering stacked event ${event.id}, stackLink:`, stackLink);
if (stackLink) {
// Apply stack link to element (for drag-drop)
this.stackManager.applyStackLinkToElement(element, stackLink);
// Apply visual styling
this.stackManager.applyVisualStyling(element, stackLink.stackLevel);
console.log(` Applied margin-left: ${stackLink.stackLevel * 15}px, stack-link:`, stackLink);
}
eventsLayer.appendChild(element); eventsLayer.appendChild(element);
}); });
} }
/**
* Calculate stack level for a grid group based on what it overlaps OUTSIDE the group
*/
private calculateGridGroupStackLevel(
group: EventGroup,
allEvents: CalendarEvent[],
stackLinks: Map<string, StackLink>
): number {
const groupEventIds = new Set(group.events.map(e => e.id));
// Find all events OUTSIDE this group
const outsideEvents = allEvents.filter(e => !groupEventIds.has(e.id));
// Find the highest stackLevel of any event that overlaps with ANY event in the grid group
let maxOverlappingLevel = -1;
for (const gridEvent of group.events) {
for (const outsideEvent of outsideEvents) {
if (this.stackManager.doEventsOverlap(gridEvent, outsideEvent)) {
const outsideLink = stackLinks.get(outsideEvent.id);
if (outsideLink) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, outsideLink.stackLevel);
}
}
}
}
// Grid group should be one level above the highest overlapping event
return maxOverlappingLevel + 1;
}
/** /**
* Render events in a grid container (side-by-side) * Render events in a grid container (side-by-side)
*/ */
private renderGridGroup(group: EventGroup, eventsLayer: HTMLElement, stackLevel: number): void { private renderGridGroup(gridGroup: GridGroupLayout, eventsLayer: HTMLElement): void {
const groupElement = document.createElement('swp-event-group'); const groupElement = document.createElement('swp-event-group');
// Add grid column class based on event count // Add grid column class based on event count
const colCount = group.events.length; const colCount = gridGroup.events.length;
groupElement.classList.add(`cols-${colCount}`); groupElement.classList.add(`cols-${colCount}`);
// Add stack level class for margin-left offset // Add stack level class for margin-left offset
groupElement.classList.add(`stack-level-${stackLevel}`); groupElement.classList.add(`stack-level-${gridGroup.stackLevel}`);
// Position based on earliest event // Position from layout
const earliestEvent = group.events[0]; groupElement.style.top = `${gridGroup.position.top}px`;
const position = this.calculateEventPosition(earliestEvent);
groupElement.style.top = `${position.top + 1}px`;
// Add z-index based on stack level // Add inline styles for margin-left and z-index (guaranteed to work)
groupElement.style.zIndex = `${this.stackManager.calculateZIndex(stackLevel)}`; groupElement.style.marginLeft = `${gridGroup.stackLevel * 15}px`;
groupElement.style.zIndex = `${this.stackManager.calculateZIndex(gridGroup.stackLevel)}`;
// Add stack-link attribute for drag-drop (group acts as a stacked item) // Add stack-link attribute for drag-drop (group acts as a stacked item)
const stackLink: StackLink = { const stackLink = {
stackLevel: stackLevel stackLevel: gridGroup.stackLevel
// prev/next will be handled by drag-drop manager if needed
}; };
this.stackManager.applyStackLinkToElement(groupElement, stackLink); this.stackManager.applyStackLinkToElement(groupElement, stackLink);
// NO height on the group - it should auto-size based on children
// Render each event within the grid // Render each event within the grid
group.events.forEach(event => { const earliestEvent = gridGroup.events[0];
gridGroup.events.forEach(event => {
const element = this.renderEventInGrid(event, earliestEvent.start); const element = this.renderEventInGrid(event, earliestEvent.start);
groupElement.appendChild(element); groupElement.appendChild(element);
}); });
@ -363,12 +279,19 @@ export class DateEventRenderer implements EventRendererStrategy {
} }
clearEvents(container?: HTMLElement): void { clearEvents(container?: HTMLElement): void {
const selector = 'swp-event'; const eventSelector = 'swp-event';
const groupSelector = 'swp-event-group';
const existingEvents = container const existingEvents = container
? container.querySelectorAll(selector) ? container.querySelectorAll(eventSelector)
: document.querySelectorAll(selector); : document.querySelectorAll(eventSelector);
const existingGroups = container
? container.querySelectorAll(groupSelector)
: document.querySelectorAll(groupSelector);
existingEvents.forEach(event => event.remove()); existingEvents.forEach(event => event.remove());
existingGroups.forEach(group => group.remove());
} }
protected getColumns(container: HTMLElement): HTMLElement[] { protected getColumns(container: HTMLElement): HTMLElement[] {

View file

@ -410,7 +410,7 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
// PHASE 3: Nested Stacking (Late Arrivals) // PHASE 3: Nested Stacking (Late Arrivals)
// ============================================ // ============================================
describe('Phase 3: Nested Stacking in Flexbox', () => { describe.skip('Phase 3: Nested Stacking in Flexbox (NOT IMPLEMENTED)', () => {
it('should identify late arrivals (events starting > 15 min after group)', () => { it('should identify late arrivals (events starting > 15 min after group)', () => {
const groups = [ const groups = [
{ {
@ -541,7 +541,7 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
// Flexbox Layout Calculations // Flexbox Layout Calculations
// ============================================ // ============================================
describe('Flexbox Layout Calculation', () => { describe.skip('Flexbox Layout Calculation (REMOVED)', () => {
it('should calculate 50% flex width for 2-column flexbox', () => { it('should calculate 50% flex width for 2-column flexbox', () => {
const width = manager.calculateFlexWidth(2); const width = manager.calculateFlexWidth(2);
@ -954,7 +954,7 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
// (they're side-by-side, not stacked) // (they're side-by-side, not stacked)
}); });
it('Scenario 6: Grid + D nested in B column', () => { it.skip('Scenario 6: Grid + D nested in B column (NOT IMPLEMENTED - requires Phase 3)', () => {
// Event A: 10:00 - 13:00 // Event A: 10:00 - 13:00
// Event B: 11:00 - 12:30 (flexbox column 1) // Event B: 11:00 - 12:30 (flexbox column 1)
// Event C: 11:00 - 12:00 (flexbox column 2) // Event C: 11:00 - 12:00 (flexbox column 2)

View file

@ -7,12 +7,15 @@
* 3. Refactor if needed (REFACTOR) * 3. Refactor if needed (REFACTOR)
* *
* @see STACKING_CONCEPT.md for concept documentation * @see STACKING_CONCEPT.md for concept documentation
*
* NOTE: This test file is SKIPPED as it tests removed methods (createStackLinks, findOverlappingEvents)
* See EventStackManager.flexbox.test.ts for current implementation tests
*/ */
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { EventStackManager, StackLink } from '../../src/managers/EventStackManager'; import { EventStackManager, StackLink } from '../../src/managers/EventStackManager';
describe('EventStackManager - TDD Suite', () => { describe.skip('EventStackManager - TDD Suite (DEPRECATED - uses removed methods)', () => {
let manager: EventStackManager; let manager: EventStackManager;
beforeEach(() => { beforeEach(() => {