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:
parent
b590467f60
commit
faa59f6a3c
19 changed files with 1502 additions and 55 deletions
217
.workbench/review.txt
Normal file
217
.workbench/review.txt
Normal 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: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
|
||||
Loading…
Add table
Add a link
Reference in a new issue