You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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. 预处理事件:

    • 复制事件并按开始时间排序(保留原逻辑)
    • 计算每个事件的顶部偏移和高度(复用原有计算方法)
  2. 生成时间轴关键节点:

    • 收集所有事件的开始、结束时间点,去重后排序,得到时间轴上的分割点
  3. 统计各区间的重叠事件数:

    • 遍历相邻时间点组成的区间,统计每个区间内同时存在的事件数量
    • 记录每个事件覆盖的所有区间中的最大重叠数
  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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.30 17:12:34