324 lines
8.8 KiB
JavaScript
324 lines
8.8 KiB
JavaScript
|
|
/**
|
||
|
|
* 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 = `
|
||
|
|
<swp-event-time>${formatTime(event.start)} - ${formatTime(event.end)}</swp-event-time>
|
||
|
|
<swp-event-title>${event.title}</swp-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 };
|