Grid Layoutでバーチカルカレンダーを作る

2024/12/15に公開

これは 株式会社TimeTree Advent Calendar 2024 の15日目の記事です。

はじめに

おはようございます。TimeTreeというカレンダーアプリのウェブ版を開発をしているSaul(ソール)です。

今回はカレンダーでよくあるバーチカルカレンダーの作り方について解説していきます。

https://timetreeapp.com/

バーチカルカレンダーの概要

バーチカルカレンダーとは、カレンダーを縦方向にレイアウトした形式のことです。ウィークリーやデイリー、3日間など日数は可変です。
↓Notion Calendarのウィークリーの例

今回は以下の素朴なカレンダーを題材にして進めます。

仕様

ざっくりな仕様は以下のとおりです。

  • 縦軸は時刻、横軸は日付
  • 予定の開始時刻から終了時刻までを表現する
  • 時間が重なった予定は、横に並べる
  • 1日のバーチカルを複数並べて複数日を表現する

1日分ができれば、あとは横に並べるだけなので、1日バーチカルに焦点を当てて説明してきます。

Grid Layoutによる配置

一日のバーチカルをdev toolsを使って見ると、このようになっています。

まず大枠のgridの設定を一部抜粋すると

grid-template-rows: repeat(1440, 0.8333333333333334px);
grid-template-columns: repeat(12, 1fr);

次に各予定のDOMを一部見るとこんな感じ。

<!-- row:540, column:1, row-span:180, column-span:3  -->
<div style="grid-area: 540 / 1 / span 180 / span 3;">Event 1</div>

<!-- row:780, column:1, row-span:60, column-span:4  -->
<div style="grid-area: 780 / 1 / span 60 / span 4;">Event 6</div>

<!-- row:840, column:1, row-span:60, column-span:12  -->
<div style="grid-area: 840 / 1 / span 60 / span 12;">Event 4</div>

rowを12分割、columnsを1440分割したgridエリアに、予定の時間と同時刻の重なり数を見て配置を決めています。

分割数が多すぎて自分でもびっくりしますが、一つひとつ見ていきたいと思います。

事前準備

説明を始める前に、登場人物の説明をしましょう。

luxon

日付の扱いはluxonでおこないます。
https://moment.github.io/luxon/#/

予定の型

予定のタイトルや開始日時など、配置する上で最低限の型を定義します。

import type { DateTime } from "luxon";

/**
 * カレンダー上に表示するイベントを表す基本的な型。
 * - `title`: イベントのタイトル
 * - `startAt`: イベント開始時刻
 * - `endAt`: イベント終了時刻
 */
type CalendarEvent = {
  title: string;
  startAt: DateTime;
  endAt: DateTime;
};

以下の予定サンプルを使います。

export const sampleEventList = [
  {
    title: "Event 1",
    startAt: DateTime.fromISO("2024-09-12T09:00"),
    endAt: DateTime.fromISO("2024-09-12T12:00"),
  },
  {
    title: "Event 2",
    startAt: DateTime.fromISO("2024-09-12T10:00"),
    endAt: DateTime.fromISO("2024-09-12T12:00"),
  },
  {
    title: "Event 3",
    startAt: DateTime.fromISO("2024-09-12T11:00"),
    endAt: DateTime.fromISO("2024-09-12T13:00"),
  },
  {
    title: "Event 4",
    startAt: DateTime.fromISO("2024-09-12T14:00"),
    endAt: DateTime.fromISO("2024-09-12T15:00"),
  },
  {
    title: "Event 5",
    startAt: DateTime.fromISO("2024-09-12T09:30"),
    endAt: DateTime.fromISO("2024-09-12T12:30"),
  },
  {
    title: "Event 6",
    startAt: DateTime.fromISO("2024-09-12T13:00"),
    endAt: DateTime.fromISO("2024-09-12T14:00"),
  },
  {
    title: "Event 7",
    startAt: DateTime.fromISO("2024-09-12T13:00"),
    endAt: DateTime.fromISO("2024-09-12T14:00"),
  },
  {
    title: "Event 8",
    startAt: DateTime.fromISO("2024-09-12T13:00"),
    endAt: DateTime.fromISO("2024-09-12T14:00"),
  },
];

予定配置の型

Grid上に配置するために、columnなどのプロパティをもたせた型も定義しておきます。

/**
 * イベントに対して、描画上の位置(行・列)とスパン(表示幅・高さ)を付加した型。
 * - `column`, `columnSpan`: イベントが配置される列位置と列幅
 * - `row`, `rowSpan`: イベントが配置される行位置と高さ(分単位)
 */
type EventCoordinate = CalendarEvent & {
  column: number;
  columnSpan: number;
  row: number;
  rowSpan: number;
};

ユーティリティ関数群

最小公倍数を求める場面があるので、以下のような関数を使います。

/**
 * 最大公約数(GCD: Greatest Common Divisor)を求める関数
 * ユークリッドの互除法を使用
 */
const gcd = (a: number, b: number): number => {
  let x = a;
  let y = b;
  while (y !== 0) {
    [x, y] = [y, x % y];
  }
  return x;
};

/**
 * 最小公倍数(LCM: Least Common Multiple)を求める関数
 * LCM(a,b) = (a * b) / GCD(a,b)
 */
const lcm = (a: number, b: number): number => (a * b) / gcd(a, b);

それでは本題に入りましょう。

大枠のgrid

まずはこの膨大なgridのラインについて。

grid-template-rows: repeat(1440, 0.8333333333333334px);
grid-template-columns: repeat(12, 1fr);

rowsの1440 ですが、これは一日(24時間)を1分刻み(60分割)にしています

24 * 60 = 1440

ちなみに0.8333333333333334pxは一行(1分)の高さです。これは任意でよいのですが、ここでは1時間50pxとして60分割しています。

50 / 60 = 0.8333333333333334

次にcolumnsの12ですが、予定の重なり数と重なりのパターンに応じて算出しています。

/**
 * 各イベントについて、そのイベントが同時間帯に重なるイベント数を計算する関数。
 * 重なりの条件:
 * (eventA.startAt < eventB.endAt) && (eventB.startAt < eventA.endAt)
 *
 * @param events 処理対象のイベント一覧
 * @returns 各イベントごとの重なり数配列
 */
const computeOverlappingCounts = (events: readonly CalendarEvent[]): number[] => {
  return events.map((event) =>
    events.reduce((count, otherEvent) => {
      const overlaps = event.startAt < otherEvent.endAt && otherEvent.startAt < event.endAt;
      return overlaps ? count + 1 : count;
    }, 0),
  );
};

// computeOverlappingCounts(sampleEventList) => [4,4,4,1,4,3,3,3]

次に重なり数の最小公倍数を算出します。

/**
 * 全イベントの重なり数から、全体で必要な列数(カラム数)を求める関数。
 * 重なり数のユニークな値群の最小公倍数をとることで必要カラム数を求める。
 *
 * 例:
 * 重なり数が[2,3,2,3]であればユニークは[2,3]、LCM(2,3)=6がカラム数となる。
 *
 * @param overlappingCounts 各イベントの重なり数の配列
 * @returns 必要な全体のカラム数
 */
const computeTotalColumns = (overlappingCounts: number[]): number => {
  const uniqueOverlaps = Array.from(new Set(overlappingCounts));
  return uniqueOverlaps.reduce((acc, val) => lcm(acc, val), 1);
};

// computeTotalColumns(computeOverlappingCounts(sampleEventList)) => 12

これでカラム数を算出できました。

ちなみにこのあたりの処理は、iOSエンジニアのbaxの記事を大いに参考にさせていただきました 🙏🏻

https://qiita.com/bax/items/c9793586ba3e32e8feb1

予定の配置

大枠ができたので予定を配置していきます。

row(縦軸)はシンプルですね。分刻みでrowが配置されてるので、0時0分からの差分をみると決まります。

const row = startAt.diff(beginAt, "minutes").minutes;
const rowSpan = endAt.diff(event.startAt, "minutes").minutes;

次にcolumn(横軸)です。まずはeventsを開始時間でソートします。

/**
 * 内部的な処理で利用するイベント型。
 * 元の `CalendarEvent` に対し `index` プロパティを付加。
 * 列割り当てなどの処理でインデックス操作が必要になるため。
 */
type EventWithIndex = CalendarEvent & { index: number };

/**
 * イベントを開始時刻順にソートする関数。
 * カラム割り当てなどは開始時刻の昇順で処理することで、
 * 時系列的に合理的な配置を行うことができる。
 *
 * @param events イベント一覧
 * @returns ソートされ、indexプロパティが付加されたイベント配列
 */
const sortEventsByStart = (events: readonly CalendarEvent[]): EventWithIndex[] => {
  return events
    .map((event, index) => ({ ...event, index }))
    .sort((a, b) => a.startAt.toMillis() - b.startAt.toMillis());
};

// 結果
// サンプルデータでのソート結果(開始時刻昇順):
// E1(09:00), E5(09:30), E2(10:00), E3(11:00), E6(13:00), E7(13:00), E8(13:00), E4(14:00)
// index(元のevents配列でのインデックス)は下記のような対応:
//   E1: index=0
//   E2: index=1
//   E3: index=2
//   E4: index=3
//   E5: index=4
//   E6: index=5
//   E7: index=6
//   E8: index=7

次にカラムを割り当てます。

/**
 * ソート済みのイベントに対してカラム(列)を割り当てる関数。
 *
 * ロジック:
 * - 開始時刻順にイベントを処理し、現在のカラムに配置できるかをチェック
 * - 配置できるカラムがなければ新規カラムを追加
 *
 * columnsEndTimesには各カラムの「最後に終了したイベントの終了時刻」を保持。
 * 新規イベントがこの時刻以降に開始すれば、そのカラムを再利用可能。
 *
 * @param sortedEvents 開始時刻でソート済みかつindexを持つイベント一覧
 * @returns イベントごとの割り当てカラム情報(インデックスと対応)
 */
const assignColumnsToEvents = (sortedEvents: EventWithIndex[]): number[] => {
  const eventColumns: number[] = new Array(sortedEvents.length).fill(0);
  const columnsEndTimes: DateTime[] = [];

  for (const event of sortedEvents) {
    const { startAt, endAt, index } = event;
    let assigned = false;

    // 既存カラムの中から再利用可能なものを探す
    for (let col = 0; col < columnsEndTimes.length; col++) {
      // カラムの終了時刻がこのイベント開始前までに終わっていれば再利用可能
      if (columnsEndTimes[col] <= startAt) {
        columnsEndTimes[col] = endAt;
        eventColumns[index] = col;
        assigned = true;
        break;
      }
    }

    // 再利用可能なカラムがなければ新規カラムを作る
    if (!assigned) {
      columnsEndTimes.push(endAt);
      eventColumns[index] = columnsEndTimes.length - 1;
    }
  }

  return eventColumns;
};

// 結果
// サンプルデータでの結果(元のindex順に並べると):
//   E1 → カラム0
//   E2 → カラム2
//   E3 → カラム3
//   E4 → カラム0
//   E5 → カラム1
//   E6 → カラム0
//   E7 → カラム1
//   E8 → カラム2
// よってeventColumns = [0,2,3,0,1,0,1,2]

処理のイメージ。

時間軸:    09:00           09:30    09:45    10:00           11:00
           |---------------|--------|--------|---------------|
カラム0:   E1(09:00-10:00)        (10:00以降解放)
カラム1:                E2(09:30-09:45)
                         (09:45以降解放) E3(10:00-11:00)

eventColumns = [0, 1, 1]

あとはここまでに作った関数を総動員して、各予定のcolumnとcolumn-spanを算出します。rowも一緒に算出する関数として定義しました。

/**
 * イベントの座標情報を計算する関数。
 *
 * `row`・`rowSpan`:
 * - `row`はbeginAtからの分差で算出
 * - `rowSpan`はイベントの長さ(分)
 *
 * `column`・`columnSpan`:
 * - `maxOverlap`はそのイベントが重なるイベント数
 * - 全体カラム数(totalColumns)をmaxOverlapで割ることで、そのイベントが占有する列幅(columnSpan)を算出
 * - カラム割り当て結果(eventColumns[index])から、イベントが開始するcolumn位置を求める
 *
 * @param events 元イベント一覧
 * @param overlappingCounts 各イベントごとの重なり数
 * @param totalColumns 全体のカラム数
 * @param eventColumns 各イベントが割り当てられたカラムインデックス
 * @param beginAt カレンダー表示基準時刻
 * @returns 各イベントの座標・スパンを付加した`EventCoordinate`配列
 */
const computeEventCoordinates = (
  events: readonly CalendarEvent[],
  overlappingCounts: number[],
  totalColumns: number,
  eventColumns: number[],
  beginAt: DateTime
): EventCoordinate[] => {
  return events.map((event, index) => {
    const { startAt, endAt } = event;

    // 行情報(row, rowSpan)の計算
    // row: 基準時刻からイベント開始時刻までの分差
    // rowSpan: イベント長(分)
    const rowSpan = endAt.diff(startAt, "minutes").minutes;
    const row = startAt.diff(beginAt, "minutes").minutes;

    // 列情報(column, columnSpan)の計算
    // columnSpan: 全カラム数 / 最大重なり数
    const maxOverlap = overlappingCounts[index];
    const columnSpan = totalColumns / maxOverlap;

    // eventColumns[index]がイベントが属するカラムインデックス
    // columnはcolumnSpan単位でずらして計算する
    const col = eventColumns[index];
    const column = col * columnSpan + 1;

    return {
      ...event,
      column,
      columnSpan,
      row,
      rowSpan,
    };
  });
};

最終的な処理を見てみましょう。

// beginAt: 2024-09-12T00:00
// events: [E1(9:00-12:00), E2(10:00-12:00), E3(11:00-13:00), E4(14:00-15:00),
//          E5(9:30-12:30), E6(13:00-14:00), E7(13:00-14:00), E8(13:00-14:00)]

export const getEventCoordinates = ({ beginAt, events }: Options) => {
  // 1. 各イベントの重なり数を計算
  const overlappingCounts = computeOverlappingCounts(events);
  // サンプルデータでの結果: overlappingCounts = [4,4,4,1,4,3,3,3]
  // (E1,E2,E3,E4,E5,E6,E7,E8の順で計算)

  // 2. 全体カラム数を求める
  const totalColumns = computeTotalColumns(overlappingCounts);
  // サンプルデータでの結果: totalColumns = 12

  // 3. イベントを開始時刻順にソート
  const sortedEvents = sortEventsByStart(events);
  // サンプルデータでのソート結果(開始時刻昇順):
  // E1(09:00), E5(09:30), E2(10:00), E3(11:00), E6(13:00), E7(13:00), E8(13:00), E4(14:00)
  // index(元のevents配列でのインデックス)は下記のような対応:
  //   E1: index=0
  //   E2: index=1
  //   E3: index=2
  //   E4: index=3
  //   E5: index=4
  //   E6: index=5
  //   E7: index=6
  //   E8: index=7

  // 4. ソート済みイベントにカラム割り当て
  const eventColumns = assignColumnsToEvents(sortedEvents);
  // サンプルデータでの結果(元のindex順に並べると):
  //   E1 → カラム0
  //   E2 → カラム2
  //   E3 → カラム3
  //   E4 → カラム0
  //   E5 → カラム1
  //   E6 → カラム0
  //   E7 → カラム1
  //   E8 → カラム2
  // よってeventColumns = [0,2,3,0,1,0,1,2]

  // 5. 各イベントの座標情報を計算
  const eventCoordinates = computeEventCoordinates(
    events,
    overlappingCounts,
    totalColumns,
    eventColumns,
    beginAt
  );
  // サンプルデータでの結果:
  // E1: column=1,columnSpan=3,row=540,rowSpan=180
  // E2: column=7,columnSpan=3,row=600,rowSpan=120
  // E3: column=10,columnSpan=3,row=660,rowSpan=120
  // E4: column=1,columnSpan=12,row=840,rowSpan=60
  // E5: column=4,columnSpan=3,row=570,rowSpan=180
  // E6: column=1,columnSpan=4,row=780,rowSpan=60
  // E7: column=5,columnSpan=4,row=780,rowSpan=60
  // E8: column=9,columnSpan=4,row=780,rowSpan=60

  return { eventCoordinates, columns: totalColumns };
};

各予定のDOMは以下のようになります。

<div style="grid-area: 540 / 1 / span 180 / span 3;">Event 1</div>
<div style="grid-area: 600 / 7 / span 120 / span 3;">Event 2</div>
<div style="grid-area: 660 / 10 / span 120 / span 3;">Event 3</div>
<div style="grid-area: 840 / 1 / span 60 / span 12;">Event 4</div>
<div style="grid-area: 570 / 4 / span 180 / span 3;">Event 5</div>
<div style="grid-area: 780 / 1 / span 60 / span 4;">Event 6</div>
<div style="grid-area: 780 / 5 / span 60 / span 4;">Event 7</div>
<div style="grid-area: 780 / 9 / span 60 / span 4;">Event 8</div>

さいごに

いかがだったでしょうか。サラッと説明してきましたが、実際作ってるときは試行錯誤の連続でした。
Grid数が膨大なのでパフォーマンスに問題がないか不安でしたが、100予定くらいレンダリングする分には目に見える遅さは感じませんでした。

他の実装方法としては、各予定をabsoluteにして配置したり、ライブラリも使った方法もあります。本記事の方法は一例としてお楽しみいただければと思います。

最後の最後に、おさらいです。
まずサンプル予定リストはこちら。

export const sampleEventList = [
  {
    title: "Event 1",
    startAt: DateTime.fromISO("2024-09-12T09:00"),
    endAt: DateTime.fromISO("2024-09-12T12:00"),
  },
  {
    title: "Event 2",
    startAt: DateTime.fromISO("2024-09-12T10:00"),
    endAt: DateTime.fromISO("2024-09-12T12:00"),
  },
  {
    title: "Event 3",
    startAt: DateTime.fromISO("2024-09-12T11:00"),
    endAt: DateTime.fromISO("2024-09-12T13:00"),
  },
  {
    title: "Event 4",
    startAt: DateTime.fromISO("2024-09-12T14:00"),
    endAt: DateTime.fromISO("2024-09-12T15:00"),
  },
  {
    title: "Event 5",
    startAt: DateTime.fromISO("2024-09-12T09:30"),
    endAt: DateTime.fromISO("2024-09-12T12:30"),
  },
  {
    title: "Event 6",
    startAt: DateTime.fromISO("2024-09-12T13:00"),
    endAt: DateTime.fromISO("2024-09-12T14:00"),
  },
  {
    title: "Event 7",
    startAt: DateTime.fromISO("2024-09-12T13:00"),
    endAt: DateTime.fromISO("2024-09-12T14:00"),
  },
  {
    title: "Event 8",
    startAt: DateTime.fromISO("2024-09-12T13:00"),
    endAt: DateTime.fromISO("2024-09-12T14:00"),
  },
];

各自紹介してきた関数たちをまとめると以下の様になります。

import type { DateTime } from "luxon";

/**
 * カレンダー上に表示するイベントを表す基本的な型。
 * - `id`: イベントを一意に識別するID
 * - `title`: イベントのタイトル
 * - `startAt`: イベント開始時刻
 * - `endAt`: イベント終了時刻
 */
type CalendarEvent = {
  id: string;
  title: string;
  startAt: DateTime;
  endAt: DateTime;
};

/**
 * `getEventCoordinates`関数に渡すオプション型。
 * - `beginAt`: カレンダー表示開始基準時刻
 * - `events`: 処理対象のイベント一覧
 */
type Options = Readonly<{
  beginAt: DateTime;
  events: readonly CalendarEvent[];
}>;

/**
 * イベントに対して、描画上の位置(行・列)とスパン(表示幅・高さ)を付加した型。
 * - `column`, `columnSpan`: イベントが配置される列位置と列幅
 * - `row`, `rowSpan`: イベントが配置される行位置と高さ(分単位)
 */
type EventCoordinate = CalendarEvent & {
  column: number;
  columnSpan: number;
  row: number;
  rowSpan: number;
};

/**
 * 内部的な処理で利用するイベント型。
 * 元の `CalendarEvent` に対し `index` プロパティを付加。
 * 列割り当てなどの処理でインデックス操作が必要になるため。
 */
type EventWithIndex = CalendarEvent & { index: number };

// ---------- ユーティリティ関数群 ----------

/**
 * 最大公約数(GCD: Greatest Common Divisor)を求める関数
 * ユークリッドの互除法を使用
 */
const gcd = (a: number, b: number): number => {
  let x = a;
  let y = b;
  while (y !== 0) {
    [x, y] = [y, x % y];
  }
  return x;
};

/**
 * 最小公倍数(LCM: Least Common Multiple)を求める関数
 * LCM(a,b) = (a * b) / GCD(a,b)
 */
const lcm = (a: number, b: number): number => (a * b) / gcd(a, b);

// ---------- コアロジック関数群 ----------

/**
 * 各イベントについて、そのイベントが同時間帯に重なるイベント数を計算する関数。
 * 重なりの条件:
 * (eventA.startAt < eventB.endAt) && (eventB.startAt < eventA.endAt)
 *
 * @param events 処理対象のイベント一覧
 * @returns 各イベントごとの重なり数配列
 */
const computeOverlappingCounts = (events: readonly CalendarEvent[]): number[] => {
  return events.map((event) =>
    events.reduce((count, otherEvent) => {
      const overlaps = event.startAt < otherEvent.endAt && otherEvent.startAt < event.endAt;
      return overlaps ? count + 1 : count;
    }, 0),
  );
};

/**
 * 全イベントの重なり数から、全体で必要な列数(カラム数)を求める関数。
 * 重なり数のユニークな値群の最小公倍数をとることで必要カラム数を求める。
 *
 * 例:
 * 重なり数が[2,3,2,3]であればユニークは[2,3]、LCM(2,3)=6がカラム数となる。
 *
 * @param overlappingCounts 各イベントの重なり数の配列
 * @returns 必要な全体のカラム数
 */
const computeTotalColumns = (overlappingCounts: number[]): number => {
  const uniqueOverlaps = Array.from(new Set(overlappingCounts));
  return uniqueOverlaps.reduce((acc, val) => lcm(acc, val), 1);
};

/**
 * イベントを開始時刻順にソートする関数。
 * カラム割り当てなどは開始時刻の昇順で処理することで、
 * 時系列的に合理的な配置を行うことができる。
 *
 * @param events イベント一覧
 * @returns ソートされ、indexプロパティが付加されたイベント配列
 */
const sortEventsByStart = (events: readonly CalendarEvent[]): EventWithIndex[] => {
  return events
    .map((event, index) => ({ ...event, index }))
    .sort((a, b) => a.startAt.toMillis() - b.startAt.toMillis());
};

/**
 * ソート済みのイベントに対してカラム(列)を割り当てる関数。
 *
 * ロジック:
 * - 開始時刻順にイベントを処理し、現在のカラムに配置できるかをチェック
 * - 配置できるカラムがなければ新規カラムを追加
 *
 * columnsEndTimesには各カラムの「最後に終了したイベントの終了時刻」を保持。
 * 新規イベントがこの時刻以降に開始すれば、そのカラムを再利用可能。
 *
 * @param sortedEvents 開始時刻でソート済みかつindexを持つイベント一覧
 * @returns イベントごとの割り当てカラム情報(インデックスと対応)
 */
const assignColumnsToEvents = (sortedEvents: EventWithIndex[]): number[] => {
  const eventColumns: number[] = new Array(sortedEvents.length).fill(0);
  const columnsEndTimes: DateTime[] = [];

  for (const event of sortedEvents) {
    const { startAt, endAt, index } = event;
    let assigned = false;

    // 既存カラムの中から再利用可能なものを探す
    for (let col = 0; col < columnsEndTimes.length; col++) {
      // カラムの終了時刻がこのイベント開始前までに終わっていれば再利用可能
      if (columnsEndTimes[col] <= startAt) {
        columnsEndTimes[col] = endAt;
        eventColumns[index] = col;
        assigned = true;
        break;
      }
    }

    // 再利用可能なカラムがなければ新規カラムを作る
    if (!assigned) {
      columnsEndTimes.push(endAt);
      eventColumns[index] = columnsEndTimes.length - 1;
    }
  }

  return eventColumns;
};

/**
 * イベントの座標情報を計算する関数。
 *
 * `row`・`rowSpan`:
 * - `row`はbeginAtからの分差で算出
 * - `rowSpan`はイベントの長さ(分)
 *
 * `column`・`columnSpan`:
 * - `maxOverlap`はそのイベントが重なるイベント数
 * - 全体カラム数(totalColumns)をmaxOverlapで割ることで、そのイベントが占有する列幅(columnSpan)を算出
 * - カラム割り当て結果(eventColumns[index])から、イベントが開始するcolumn位置を求める
 *
 * @param events 元イベント一覧
 * @param overlappingCounts 各イベントごとの重なり数
 * @param totalColumns 全体のカラム数
 * @param eventColumns 各イベントが割り当てられたカラムインデックス
 * @param beginAt カレンダー表示基準時刻
 * @returns 各イベントの座標・スパンを付加した`EventCoordinate`配列
 */
const computeEventCoordinates = (
  events: readonly CalendarEvent[],
  overlappingCounts: number[],
  totalColumns: number,
  eventColumns: number[],
  beginAt: DateTime
): EventCoordinate[] => {
  return events.map((event, index) => {
    const { startAt, endAt } = event;

    // 行情報(row, rowSpan)の計算
    // row: 基準時刻からイベント開始時刻までの分差
    // rowSpan: イベント長(分)
    const rowSpan = endAt.diff(startAt, "minutes").minutes;
    const row = startAt.diff(beginAt, "minutes").minutes;

    // 列情報(column, columnSpan)の計算
    // columnSpan: 全カラム数 / 最大重なり数
    const maxOverlap = overlappingCounts[index];
    const columnSpan = totalColumns / maxOverlap;

    // eventColumns[index]がイベントが属するカラムインデックス
    // columnはcolumnSpan単位でずらして計算する
    const col = eventColumns[index];
    const column = col * columnSpan + 1;

    return {
      ...event,
      column,
      columnSpan,
      row,
      rowSpan,
    };
  });
};

// ---------- メイン関数 ----------

/**
 * イベント一覧から、各イベントをカレンダー上にどの列・行位置に配置するかを計算する関数。
 * 返り値には、座標計算済みのイベント配列と、全体で必要なカラム数を返す。
 *
 * 処理フロー:
 * 1. 重なり数を計算(computeOverlappingCounts)
 * 2. 全体カラム数計算(computeTotalColumns)
 * 3. イベントを開始時間でソート(sortEventsByStart)
 * 4. ソート済みイベントにカラム割り当て(assignColumnsToEvents)
 * 5. 上記情報を基に各イベントの座標情報を算出(computeEventCoordinates)
 *
 * @param param0 beginAt: 基準時刻, events: イベント一覧
 * @returns { eventCoordinates, columns }
 * eventCoordinates: イベントに座標(column, row)やスパンを付与した配列
 * columns: 全体の必要カラム数
 */
export const getEventCoordinates = ({ beginAt, events }: Options) => {
  // 1. 各イベントの重なり数を計算
  const overlappingCounts = computeOverlappingCounts(events);

  // 2. 全体カラム数を求める
  const totalColumns = computeTotalColumns(overlappingCounts);

  // 3. イベントを開始時刻順にソート
  const sortedEvents = sortEventsByStart(events);

  // 4. ソート済みイベントにカラム割り当て
  const eventColumns = assignColumnsToEvents(sortedEvents);

  // 5. 各イベントの座標情報を計算
  const eventCoordinates = computeEventCoordinates(
    events,
    overlappingCounts,
    totalColumns,
    eventColumns,
    beginAt
  );

  return { eventCoordinates, columns: totalColumns };
};

以上、どなたかの役に立ったら幸いです。

TimeTree Tech Blog

Discussion