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.
217 lines
No EOL
8.2 KiB
Text
217 lines
No EOL
8.2 KiB
Text
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:00–23: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 |