/** * V2 Scenario Renderer * Uses EventLayoutEngine from V2 to render events dynamically */ // Import the compiled V2 layout engine // We'll inline the algorithm here for standalone use in browser // ============================================ // EventLayoutEngine (copied from V2 for browser use) // ============================================ function eventsOverlap(a, b) { return a.start < b.end && a.end > b.start; } function eventsWithinThreshold(a, b, thresholdMinutes) { const thresholdMs = thresholdMinutes * 60 * 1000; const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime()); if (startToStartDiff <= thresholdMs) return true; const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime(); if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true; const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime(); if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true; return false; } function findOverlapGroups(events) { if (events.length === 0) return []; const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const used = new Set(); const groups = []; for (const event of sorted) { if (used.has(event.id)) continue; const group = [event]; used.add(event.id); let expanded = true; while (expanded) { expanded = false; for (const candidate of sorted) { if (used.has(candidate.id)) continue; const connects = group.some(member => eventsOverlap(member, candidate)); if (connects) { group.push(candidate); used.add(candidate.id); expanded = true; } } } groups.push(group); } return groups; } function findGridCandidates(events, thresholdMinutes) { if (events.length === 0) return []; const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const used = new Set(); const groups = []; for (const event of sorted) { if (used.has(event.id)) continue; const group = [event]; used.add(event.id); let expanded = true; while (expanded) { expanded = false; for (const candidate of sorted) { if (used.has(candidate.id)) continue; const connects = group.some(member => eventsWithinThreshold(member, candidate, thresholdMinutes) ); if (connects) { group.push(candidate); used.add(candidate.id); expanded = true; } } } groups.push(group); } return groups; } function calculateStackLevels(events) { const levels = new Map(); const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); for (const event of sorted) { let maxOverlappingLevel = -1; for (const [id, level] of levels) { const other = events.find(e => e.id === id); if (other && eventsOverlap(event, other)) { maxOverlappingLevel = Math.max(maxOverlappingLevel, level); } } levels.set(event.id, maxOverlappingLevel + 1); } return levels; } function allocateColumns(events) { const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const columns = []; for (const event of sorted) { let placed = false; for (const column of columns) { const canFit = !column.some(e => eventsOverlap(event, e)); if (canFit) { column.push(event); placed = true; break; } } if (!placed) { columns.push([event]); } } return columns; } function calculateEventPosition(start, end, config) { const startMinutes = start.getHours() * 60 + start.getMinutes(); const endMinutes = end.getHours() * 60 + end.getMinutes(); const dayStartMinutes = config.dayStartHour * 60; const minuteHeight = config.hourHeight / 60; const top = (startMinutes - dayStartMinutes) * minuteHeight; const height = (endMinutes - startMinutes) * minuteHeight; return { top, height }; } function calculateColumnLayout(events, config) { const thresholdMinutes = config.gridStartThresholdMinutes ?? 30; const result = { grids: [], stacked: [] }; if (events.length === 0) return result; const overlapGroups = findOverlapGroups(events); for (const overlapGroup of overlapGroups) { if (overlapGroup.length === 1) { result.stacked.push({ event: overlapGroup[0], stackLevel: 0 }); continue; } const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes); const largestGridCandidate = gridSubgroups.reduce((max, g) => g.length > max.length ? g : max, gridSubgroups[0]); if (largestGridCandidate.length === overlapGroup.length) { const columns = allocateColumns(overlapGroup); const earliest = overlapGroup.reduce((min, e) => e.start < min.start ? e : min, overlapGroup[0]); const position = calculateEventPosition(earliest.start, earliest.end, config); result.grids.push({ events: overlapGroup, columns, stackLevel: 0, position: { top: position.top } }); } else { const levels = calculateStackLevels(overlapGroup); for (const event of overlapGroup) { result.stacked.push({ event, stackLevel: levels.get(event.id) ?? 0 }); } } } return result; } // ============================================ // Scenario Renderer // ============================================ const gridConfig = { hourHeight: 80, dayStartHour: 8, dayEndHour: 20, snapInterval: 15, gridStartThresholdMinutes: 30 }; function formatTime(date) { return date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' }); } function createEventElement(event, config) { const element = document.createElement('swp-event'); element.dataset.eventId = event.id; const position = calculateEventPosition(event.start, event.end, config); element.style.position = 'absolute'; element.style.top = `${position.top}px`; element.style.height = `${position.height}px`; element.style.left = '2px'; element.style.right = '2px'; element.innerHTML = ` ${formatTime(event.start)} - ${formatTime(event.end)} ${event.title} `; return element; } function renderGridGroup(layout, config) { const group = document.createElement('swp-event-group'); group.classList.add(`cols-${layout.columns.length}`); group.style.top = `${layout.position.top}px`; // Stack level styling group.dataset.stackLink = JSON.stringify({ stackLevel: layout.stackLevel }); if (layout.stackLevel > 0) { group.style.marginLeft = `${layout.stackLevel * 15}px`; group.style.zIndex = `${100 + layout.stackLevel}`; } // Calculate height let maxBottom = 0; for (const event of layout.events) { const pos = calculateEventPosition(event.start, event.end, config); const eventBottom = pos.top + pos.height; if (eventBottom > maxBottom) maxBottom = eventBottom; } group.style.height = `${maxBottom - layout.position.top}px`; // Create columns layout.columns.forEach(columnEvents => { const wrapper = document.createElement('div'); wrapper.style.position = 'relative'; columnEvents.forEach(event => { const eventEl = createEventElement(event, config); const pos = calculateEventPosition(event.start, event.end, config); eventEl.style.top = `${pos.top - layout.position.top}px`; eventEl.style.left = '0'; eventEl.style.right = '0'; wrapper.appendChild(eventEl); }); group.appendChild(wrapper); }); return group; } function renderStackedEvent(event, stackLevel, config) { const element = createEventElement(event, config); element.dataset.stackLink = JSON.stringify({ stackLevel }); if (stackLevel > 0) { element.style.marginLeft = `${stackLevel * 15}px`; element.style.zIndex = `${100 + stackLevel}`; } else { element.style.zIndex = '100'; } return element; } export function renderScenario(container, events, config = gridConfig) { container.innerHTML = ''; const layout = calculateColumnLayout(events, config); // Render grids layout.grids.forEach(grid => { const groupEl = renderGridGroup(grid, config); container.appendChild(groupEl); }); // Render stacked events layout.stacked.forEach(item => { const eventEl = renderStackedEvent(item.event, item.stackLevel, config); container.appendChild(eventEl); }); return layout; } export { calculateColumnLayout, gridConfig };