前言
在日历类应用中,如何高效展示重叠事件是一个常见需求。 本文将分三步解决这一问题:事件排序 → 重叠分组 → 组内布局。
一、事件排序
目标:确保时间顺序。使用第三方库对事件按照开始时间排序,若开始时间一致,以结束时间进行排序。以 dayjs
为例:
ts
import dayjs from 'dayjs'
interface Event {
start: string
end: string
name: string
}
export function sort(events: Event[]): Evnet[] {
return events.sort((a, b) => {
if (a.start === b.start)
return dayjs(a.end).isBefore(dayjs(b.end)) ? -1 : 1
return dayjs(a.start).isBefore(dayjs(b.start)) ? -1 : 1
})
}
二、事件分组
目标:识别重叠事件放置同一组内。 先两个时间重叠的事件放在一个组内,这个组可以看成是一个新的事件,继续和下一个事件进行处理。
ts
import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import { sort } from './sort'
dayjs.extend(isSameOrAfter)
interface Group {
start: string
end: string
events: Event[]
}
export function group(events: Event[]): Group[] {
// 事件排序
const sortEvents = sort(events)
// 组的集合
const result: Group[] = []
// 当前组
let currentGroup: Group | null = null
sortEvents.forEach((event) => {
// 组为空或事件和当前组未重叠时,新建组
if (!currentGroup || dayjs(event.start).isSameOrAfter(currentGroup.end)) {
currentGroup = {
start: event.start,
end: event.end,
events: [event],
}
result.push(currentGroup)
}
else if (dayjs(event.start).isBefore(currentGroup.end)) {
// 事件的结束时间晚于当前组的结束时间,更新当前组的结束时间
if (dayjs(event.end).isAfter(currentGroup.end))
currentGroup.end = event.end
currentGroup.events.push(event)
}
})
return result
}
三、组内布局
目标:同一个组在界面在同一行,分为多个列进行展示,所以需要确定组内事件需要几列和每个事件列的位置。同一个组在界面在同一行,分为多个列进行展示,如下图。

以上面五个事件为例:
- Meeting (8:30-10:30),计算在第一个位置,记录起始下标为 0,当前组有 1 列;
- Holiday (9:00-10:00),计算在第二个位置,记录下标为 1,当前组有 2 列;
- Travel (9:30-10:30),计算在第三个位置,记录下标为 2,当前组有 3 列;
- Party (10:20-11:20),计算在第二个位置,放置在会议 2 的下面,下标同为 1,当前组有 3 列;
- Birthday (10:40-11:40),计算在第一个位置,放置在会议 1 的下面,下标同为 0,当前组有 3 列。
ts
import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import { group } from './group'
dayjs.extend(isSameOrAfter)
interface Event {
start: string
end: string
name: string
left?: number // 列标
isPlacement?: boolean // 是否布局
isStack?: boolean // 是否堆叠
}
interface Group {
start: string
end: string
events: Event[]
columnCount?: number // 列的数量
}
export function layout(events: Event[]): Map<string, number[]> {
const result = new Map<string, number[]>()
const groups = group(events)
for (const group of groups) {
const { events } = group
let columnCount = 0
let left = 0
events.forEach((event) => {
// 已布局事件 按照结束时间排序
const placementEvents = events.filter(e => e.isPlacement && !e.isStack).sort((a, b) => dayjs(a.end).isBefore(dayjs(b.end)) ? -1 : 1)
// 查找是否存在事件堆叠
const stackEvent = placementEvents.find(e => dayjs(event.start).isSameOrAfter(dayjs(e.end)))
if (stackEvent) {
// 存在堆叠事件
// 列标同堆叠事件
event.left = stackEvent.left
event.isPlacement = true
stackEvent.isStack = true
}
else {
// 无堆叠事件
event.left = left
event.isPlacement = true
// 列标加一
left++
// 列加一
columnCount++
}
})
group.columnCount = columnCount
}
for (const group of groups) {
const { events } = group
events.forEach((event) => {
result.set(event.name, [event.left ?? -1, group.columnCount || 0])
})
}
return result
}
注意点:
- 布局事件时判断是否能放置在已布局事件下面。如果有,标记已布局事件为堆叠事件,两个事件堆叠看作为一个事件;
- 为了使事件排列更紧凑,需要将已布局事件以结束时间排序来寻找结束时间早于事件开始时间的位置。