Improves event layout and stacking logic

Refactors the event layout and stacking logic based on review feedback.

This includes:
- Merging conflicting event groups to prevent inconsistencies.
- Implementing minimal stack level assignment using a min-heap.
- Consolidating styling and using DateService for drag operations.
- Adding reflow after drag and drop.
- Improving the column event filtering to include events overlapping midnight.
- Ensuring explicit sorting of events for grid layout.
This commit is contained in:
Janus C. H. Knudsen 2025-10-06 21:16:29 +02:00
parent b590467f60
commit faa59f6a3c
19 changed files with 1502 additions and 55 deletions

217
.workbench/review.txt Normal file
View file

@ -0,0 +1,217 @@
De 6 vigtigste fund (med fixes)
Gruppering kan “brygge” mellem to grupper uden at merge dem
groupEventsByStartTime finder første eksisterende gruppe med konflikt og lægger eventet deri. Hvis et nyt event konflikter med flere grupper, bliver grupperne ikke merged → inkonsistente “grid”-klumper. Løs: merge alle matchende grupper eller brug union-find/sweep-line konfliktsæt.
EventStackManager
ContainerType er “GRID” for alle grupper >1 — også ved dybe overlaps
decideContainerType returnerer altid 'GRID' når events.length > 1. Det kan være tilsigtet, men så skal du være tryg ved, at lange overlappende events, der kun næsten starter samtidigt, stadig pakkes i kolonner fremfor “stacking”. Overvej: GRID kun når samtidighed er vigtigere end varighed, ellers fald tilbage til STACKING.
EventStackManager
Stack level-algoritmen kan eskalere niveauer unødigt
createOptimizedStackLinks sætter stackLevel = max(overlappende tidligere) + 1. Det er mere “stak-tårn” end “før-ledig-kolonne” og giver højere niveauer end nødvendigt (ikke minimal farvelægning). Løs: interval partitioning med min-heap (giver laveste ledige level).
EventStackManager
Grid-top beregnes fra ét event, men børn positioneres relativt til containerStart
I koordinatoren bruges earliestEvent til top, og renderer bruger earliestEvent.start som containerStart. Det er ok — men sørg for, at earliestEvent garanteret er det tidligste i gruppen og sortér eksplicit inden brug (robusthed mod fremtidige ændringer).
EventLayoutCoordinator
EventRenderer
Drag bruger rå new Date(...) i stedet for DateService
Kan give TZ/DST-glitches. Brug samme parse/logik som resten.
EventRenderer
Ingen reflow af kolonne efter drop
handleDragEnd normaliserer DOM men recalculerer ikke layout → forkert stacking/margin efter flyt. Kald din kolonne-pipeline igen for den berørte kolonne.
EventRenderer
Bonus: getEventsForColumn matcher kun start-dato === kolonnedato; events der krydser midnat forsvinder. Overvej interval-overlap mod døgnets [00:0023:59:59.999].
EventRenderer
Målrettede patches (små og sikre)
A) Merge grupper når et event rammer flere (EventStackManager)
Erstat den nuværende “find første gruppe”-logik med merge af alle matchende:
// inde i groupEventsByStartTime
const matches: number[] = [];
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi];
const conflict = group.events.some(ge => {
const s2s = Math.abs(event.start.getTime() - ge.start.getTime()) / 60000;
if (s2s <= thresholdMinutes) return true;
const e2s = (ge.end.getTime() - event.start.getTime()) / 60000;
if (e2s > 0 && e2s <= thresholdMinutes) return true;
const rev = (event.end.getTime() - ge.start.getTime()) / 60000;
if (rev > 0 && rev <= thresholdMinutes) return true;
return false;
});
if (conflict) matches.push(gi);
}
if (matches.length === 0) {
groups.push({ events: [event], containerType: 'NONE', startTime: event.start });
} else {
// merge alle matchende grupper + dette event
const base = matches[0];
groups[base].events.push(event);
for (let i = matches.length - 1; i >= 1; i--) {
const idx = matches[i];
groups[base].events.push(...groups[idx].events);
groups.splice(idx, 1);
}
// opdatér startTime til min start
groups[base].startTime = new Date(
Math.min(...groups[base].events.map(e => e.start.getTime()))
);
}
Nu undgår du “brobygning” der splitter reelt sammenhængende grupper.
EventStackManager
B) Minimal stack level med min-heap (EventStackManager)
Udskift level-tildeling med klassisk interval partitioning:
public createOptimizedStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
const res = new Map<string, StackLink>();
if (!events.length) return res;
const sorted = [...events].sort((a,b)=> a.start.getTime() - b.start.getTime());
type Col = { level: number; end: number };
const cols: Col[] = []; // min-heap på end
const push = (c: Col) => { cols.push(c); cols.sort((x,y)=> x.end - y.end); };
for (const ev of sorted) {
const t = ev.start.getTime();
// find første kolonne der er fri
let placed = false;
for (let i = 0; i < cols.length; i++) {
if (cols[i].end <= t) { cols[i].end = ev.end.getTime(); res.set(ev.id, { stackLevel: cols[i].level }); placed = true; break; }
}
if (!placed) { const level = cols.length; push({ level, end: ev.end.getTime() }); res.set(ev.id, { stackLevel: level }); }
}
// evt. byg prev/next separat hvis nødvendigt
return res;
}
Dette giver laveste ledige niveau og undgår “trappetårne”.
EventStackManager
C) Konsolidér margin/zIndex + brug DateService i drag (EventRenderer)
Lad StackManager styre marginLeft konsekvent (og undgå magic numbers):
// renderGridGroup
groupElement.style.top = `${gridGroup.position.top}px`;
this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel); // i stedet for *15
this.stackManager.applyStackLinkToElement(groupElement, { stackLevel: gridGroup.stackLevel });
EventRenderer
Brug DateService i drag:
public handleDragMove(payload: DragMoveEventPayload): void {
if (!this.draggedClone || !payload.columnBounds) return;
const swp = this.draggedClone as SwpEventElement;
const colDate = this.dateService.parseISODate?.(payload.columnBounds.date) ?? new Date(payload.columnBounds.date);
swp.updatePosition(colDate, payload.snappedY);
}
public handleColumnChange(e: DragColumnChangeEventPayload): void {
if (!this.draggedClone) return;
const layer = e.newColumn.element.querySelector('swp-events-layer');
if (layer && this.draggedClone.parentElement !== layer) {
layer.appendChild(this.draggedClone);
const currentTop = parseFloat(this.draggedClone.style.top) || 0;
const swp = this.draggedClone as SwpEventElement;
const colDate = this.dateService.parseISODate?.(e.newColumn.date) ?? new Date(e.newColumn.date);
swp.updatePosition(colDate, currentTop);
}
}
EventRenderer
D) Reflow efter drop (EventRenderer)
Genberegn layout for den berørte kolonne:
public handleDragEnd(id: string, original: HTMLElement, clone: HTMLElement, finalColumn: ColumnBounds): void {
if (!clone || !original) { console.warn('Missing clone/original'); return; }
this.fadeOutAndRemove(original);
const cid = clone.dataset.eventId;
if (cid && cid.startsWith('clone-')) clone.dataset.eventId = cid.replace('clone-','');
clone.classList.remove('dragging');
const layer = finalColumn.element.querySelector('swp-events-layer') as HTMLElement | null;
if (layer) {
// 1) Hent kolonnens events fra din model/state (inkl. opdateret event)
const columnEvents: CalendarEvent[] = /* ... */;
// 2) Ryd
layer.querySelectorAll('swp-event, swp-event-group').forEach(el => el.remove());
// 3) Render igen via layout
this.renderColumnEvents(columnEvents, layer);
}
this.draggedClone = null;
this.originalEvent = null;
}
EventRenderer
E) Døgn-overlap i kolonnefilter (EventRenderer)
Hvis ønsket (ellers behold din nuværende):
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const d = column.dataset.date; if (!d) return [];
const start = this.dateService.parseISODate(`${d}T00:00:00`);
const end = this.dateService.parseISODate(`${d}T23:59:59.999`);
return events.filter(ev => ev.start < end && ev.end > start);
}
EventRenderer
F) Eksplicit “earliest” i GRID (Coordinator)
Gør det robust i tilfælde af usorteret input:
const earliestEvent = [...gridCandidates].sort((a,b)=> a.start.getTime()-b.start.getTime())[0];
const pos = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
EventLayoutCoordinator
Mini-noter
allocateColumns er O(n²); det er fint for typiske dagvisninger. Hvis I ser >100 events/kolonne, kan I optimere med sweep-line + min-heap.
EventLayoutCoordinator
Overvej at lade koordinatoren returnere rene layout-maps (id → {level, z, margin}) og holde DOM-påføring 100% i renderer — det gør DnD-”reflow” enklere at teste.
EventLayoutCoordinator
EventRenderer