日历项布局算法需求:最小化垂直空间的彩色条y/top计算
日历事件垂直空间优化方案
这是个非常典型的日历布局问题,核心就是让互不重叠的事件尽可能共享同一垂直行,从而最小化整体的垂直空间占用。我给你分享两种实用的实现思路,用JavaScript就能轻松搞定:
一、核心前提:先排序事件
不管用哪种算法,第一步必须先把所有事件按开始时间从小到大排序(如果开始时间相同,建议按结束时间从小到大排,这样短事件优先安排,能更高效利用空间)。这是后续判断重叠的基础:
const sortedEvents = [...events].sort((a, b) => { if (a.start !== b.start) { return a.start - b.start; } // 开始时间相同时,短事件优先 return a.end - b.end; });
二、基础实现:暴力遍历法(适合事件量少的场景)
这个方法逻辑简单易懂,适合中小规模的事件列表(比如单日日历):
- 维护一个数组
rowEnds,用来记录每一行最后一个事件的结束时间 - 遍历每个已排序的事件,找到第一个“结束时间 ≤ 当前事件开始时间”的行,把事件放到这一行
- 如果找不到这样的行,就新增一行
- 最后根据事件所在的行号,计算
top值(行号 × 固定行高)
示例代码
/** * 计算日历事件的top值,实现垂直空间最小化 * @param {Array} events - 事件列表,每个事件需包含start(开始时间戳)、end(结束时间戳) * @param {number} rowHeight - 每行的固定高度 * @returns {Array} 带top值的事件列表 */ const calculateEventPositions = (events, rowHeight = 60) => { const sortedEvents = [...events].sort((a, b) => { if (a.start !== b.start) return a.start - b.start; return a.end - b.end; }); const rowEnds = []; // 记录每行的最后一个事件的结束时间 return sortedEvents.map(event => { // 找到第一个可以容纳当前事件的行 const targetRowIndex = rowEnds.findIndex(endTime => endTime <= event.start); if (targetRowIndex !== -1) { // 复用已有行,更新该行的结束时间为当前事件的结束时间 rowEnds[targetRowIndex] = event.end; return { ...event, top: targetRowIndex * rowHeight }; } else { // 新增一行,加入当前事件的结束时间 rowEnds.push(event.end); return { ...event, top: (rowEnds.length - 1) * rowHeight }; } }); };
三、进阶优化:优先队列(最小堆)法(适合大量事件)
如果你的日历需要处理成百上千个事件,暴力遍历的findIndex会导致O(n²)的时间复杂度,这时候可以用**最小堆(优先队列)**来优化,把时间复杂度降到O(n log n):
- 用最小堆来维护当前所有行的结束时间,堆顶始终是最早结束的行
- 遍历每个事件时,取出堆顶的结束时间:
- 如果堆顶结束时间 ≤ 当前事件开始时间,就把当前事件放到这一行,然后将堆顶更新为当前事件的结束时间
- 如果堆顶结束时间 > 当前事件开始时间,说明所有行都在忙碌,新增一行,把当前事件的结束时间加入堆
- 同样根据行号计算
top值(可以在堆中同时存储行号和结束时间)
伪代码思路
// 先实现一个简单的最小堆(或者用第三方库如heap-js) class MinHeap { constructor() { this.heap = []; } // 堆的基础方法:push, pop, peek等... } const calculateEventPositionsAdvanced = (events, rowHeight = 60) => { const sortedEvents = [...events].sort((a, b) => a.start - b.start); const heap = new MinHeap(); const eventPositions = []; for (const event of sortedEvents) { if (heap.size() > 0 && heap.peek().end <= event.start) { // 复用最早结束的行 const row = heap.pop(); eventPositions.push({ ...event, top: row.index * rowHeight }); heap.push({ end: event.end, index: row.index }); } else { // 新增一行 const newRowIndex = heap.size(); eventPositions.push({ ...event, top: newRowIndex * rowHeight }); heap.push({ end: event.end, index: newRowIndex }); } } return eventPositions; };
四、额外注意点
- 事件的
start和end建议统一用时间戳(毫秒数),方便比较 - 如果你的日历是按天展示,也可以把时间转换成当天的分钟数(比如0点是0,23:59是1439),计算逻辑完全一致
- 要是需要支持事件的拖拽调整,调整后重新调用这个方法即可更新
top值
内容的提问来源于stack exchange,提问作者L G
相关产品推荐
相关产品推荐

