JavaScript日历单日视图事件布局优化方案咨询
日历单日视图事件布局优化方案
问题背景
我正在开发一款日历的单日视图功能,且无法使用外部库。最初没意识到这是个复杂问题,目前采用的朴素算法是计算每个事件的相邻事件数量,再按数量分配宽度,但该方案并非最优。当前所有事件宽度均为100%/6,我期望12-17点的4个事件宽度为100%/4,实现更合理的事件布局效果。
示例事件数据
export const dummyEvents = [ { start: new Date(new Date().setHours(8)), end: new Date(new Date().setHours(20)), id: 1, meta: {} }, { start: new Date(new Date().setHours(8)), end: new Date(new Date().setHours(10)), id: 2, meta: {} }, { start: new Date(new Date().setHours(8)), end: new Date(new Date().setHours(10)), id: 3, meta: {} }, { start: new Date(new Date().setHours(11)), end: new Date(new Date().setHours(16)), id: 4, meta: {} }, { start: new Date(new Date().setHours(15)), end: new Date(new Date().setHours(16)), id: 5, meta: {} }, { start: new Date(new Date().setHours(15)), end: new Date(new Date().setHours(16)), id: 6, meta: {} }, ];
现有算法代码
getEventsPosition() { const overlappingEvents: any[] = []; // shallow copy needed so we won't mutate the original object this.convertedEventsInTimeline = dummyEvents.map((a) => Object.assign({}, a)); // sort all events by start this.convertedEventsInTimeline.sort((a: any, b: any) => { if (a.start.getTime() > b.start.getTime()) return 1; return -1; }); // start checking events for collisions this.convertedEventsInTimeline.forEach((event: any) => { let collisionCounter = 0; const collisions: any[] = []; event.meta = { ...event.meta, top: this.calcEventOffsetTop(event), height: this.calcEventHeight(event) }; if (!event.meta.width) { event.meta = { ...event.meta, width: 0, left: 0 }; } // Comparing next events with our selected pointer for (let j = 0; j < this.convertedEventsInTimeline.length; j++) { if ( this.doesOverlap(event, this.convertedEventsInTimeline[j]) && event.id !== this.convertedEventsInTimeline[j].id ) { collisionCounter += 1; collisions.push(this.convertedEventsInTimeline[j]); } } overlappingEvents.push({ collisions: collisionCounter, id: event.id, collisionEvents: collisions }); }); overlappingEvents.sort((a, b) => { if (a.collisions < b.collisions) return 1; return -1; }); // set width and left to our events if (overlappingEvents) { overlappingEvents.forEach((overlappingGroup) => { let maxWidth = 1; let leftOffsetCounter = 0; const outterIndex = this.convertedEventsInTimeline.findIndex((e: any) => e.id === overlappingGroup.id); if (this.convertedEventsInTimeline[outterIndex].meta.width === 0) { console.log('ziadna sirka'); overlappingGroup.collisionEvents.forEach((overlappedEvent: any) => { const index = this.convertedEventsInTimeline.findIndex((e: any) => e.id === overlappedEvent.id); if (maxWidth < overlappingEvents[index].collisions) { maxWidth = overlappingEvents[index].collisions; } }); const index = this.convertedEventsInTimeline.findIndex((e: any) => e.id === overlappingGroup.id); this.convertedEventsInTimeline[index].meta.width = maxWidth + 1; overlappingGroup.collisionEvents.forEach((overlappedEvent: any) => { const innerIndex = this.convertedEventsInTimeline.findIndex((e: any) => e.id === overlappedEvent.id); this.convertedEventsInTimeline[innerIndex].meta.width = maxWidth + 1; if (leftOffsetCounter <= maxWidth) { leftOffsetCounter += 1; } else { leftOffsetCounter = 1; } this.convertedEventsInTimeline[innerIndex].meta.left = leftOffsetCounter; }); } if (overlappingGroup.collisions === 0) { this.convertedEventsInTimeline[outterIndex].meta.width = 1; } }); } return this.convertedEventsInTimeline; }
当前效果与期望效果
- 当前效果:所有事件统一使用100%/6的宽度,布局冗余
- 期望效果:不同时间区间按当前最大重叠事件数分配宽度,比如12-17点的4个事件使用100%/4的宽度,布局更紧凑合理
优化算法思路
核心逻辑:按时间区间动态计算重叠密度
现有算法的问题是全局计算每个事件的总碰撞数,导致所有事件共享同一宽度基准。优化方向是针对每个时间区间计算当前最大重叠事件数,让事件在各自覆盖的区间内,以该区间的最大重叠数为基准分配宽度。
步骤分解
预处理事件:
- 复制事件并按开始时间排序(保留原逻辑)
- 计算每个事件的顶部偏移和高度(复用原有计算方法)
生成时间轴关键节点:
- 收集所有事件的开始、结束时间点,去重后排序,得到时间轴上的分割点
统计各区间的重叠事件数:
- 遍历相邻时间点组成的区间,统计每个区间内同时存在的事件数量
- 记录每个事件覆盖的所有区间中的最大重叠数
分配宽度与左偏移:
- 对每个事件,以其覆盖区间的最大重叠数为分母,计算宽度比例(如1/4)
- 将同一重叠组内的事件按顺序分配左偏移(如0/4、1/4等)
实现代码示例
interface EventWithMeta extends typeof dummyEvents[0] { meta: { top: number; height: number; widthRatio: number; // 宽度占比,如1/4 leftRatio: number; // 左偏移占比,如0/4 }; } function optimizeEventLayout(events: typeof dummyEvents): EventWithMeta[] { // 1. 复制并排序事件 const sortedEvents = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()) as EventWithMeta[]; // 2. 计算事件的top和height(复用原有逻辑) sortedEvents.forEach(event => { event.meta = { ...event.meta, top: calcEventOffsetTop(event), height: calcEventHeight(event), widthRatio: 1, leftRatio: 0 }; }); // 3. 收集所有时间节点 const timePoints = new Set<number>(); sortedEvents.forEach(e => { timePoints.add(e.start.getTime()); timePoints.add(e.end.getTime()); }); const sortedTimePoints = Array.from(timePoints).sort((a, b) => a - b); // 4. 统计每个事件的最大重叠数 const eventOverlapMap = new Map<number, { maxOverlap: number }>(); sortedEvents.forEach(event => eventOverlapMap.set(event.id, { maxOverlap: 1 })); for (let i = 0; i < sortedTimePoints.length - 1; i++) { const start = sortedTimePoints[i]; const end = sortedTimePoints[i + 1]; if (start === end) continue; // 筛选当前区间内的活跃事件 const activeEvents = sortedEvents.filter(e => e.start.getTime() <= start && e.end.getTime() >= end ); const currentOverlap = activeEvents.length; // 更新事件的最大重叠数 activeEvents.forEach(event => { const info = eventOverlapMap.get(event.id)!; if (currentOverlap > info.maxOverlap) { info.maxOverlap = currentOverlap; } }); } // 5. 为重叠事件分配宽度和左偏移 const processedIds = new Set<number>(); sortedEvents.forEach(event => { if (processedIds.has(event.id)) return; const maxOverlap = eventOverlapMap.get(event.id)!.maxOverlap; // 找到同组的重叠事件 const group = sortedEvents.filter(e => { if (processedIds.has(e.id)) return false; return eventOverlapMap.get(e.id)!.maxOverlap === maxOverlap && doesOverlap(event, e); }); // 分配比例 group.forEach((e, idx) => { e.meta.widthRatio = 1 / maxOverlap; e.meta.leftRatio = idx / maxOverlap; processedIds.add(e.id); }); }); return sortedEvents; } // 复用原有重叠判断函数 function doesOverlap(a: any, b: any): boolean { return a.start.getTime() < b.end.getTime() && a.end.getTime() > b.start.getTime(); } // 原有top和height计算函数示例 function calcEventOffsetTop(event: any): number { return (event.start.getHours() - 8) * 60; // 假设每小时占60px } function calcEventHeight(event: any): number { return (event.end.getHours() - event.start.getHours()) * 60; }
算法优势
- 解决了全局统一宽度的问题,不同重叠密度的区间使用对应宽度基准
- 事件布局更紧凑,符合预期的示例效果
- 时间复杂度可控,适配单日视图的事件数量规模
内容的提问来源于stack exchange,提问作者Marek Panti
相关产品推荐
相关产品推荐

