BizLayout

型番 timeline

タイムライン(ロードマップ)

イベントを日付の実経過時間に比例して横軸に自動配置し、計画の密度や山場をデータ駆動で示す。補足は図の下で明示的に展開し、年表⇄リストを切り替えられる

timeline · roadmap · sequence · data-driven · interactive

LIVE SAMPLE

プレビュー

補足の開閉・表示切替を試せます

基幹システム刷新ロードマップ — 本稼働まで残り3ヶ月

2026年4月キックオフ〜2027年4月本稼働(2026年度 IT投資計画・サンプル)

補足を確認7

要件定義キックオフ

2026/4/1

現行業務フローの棚卸しと新システムのスコープ確定から着手

基本設計完了

2026/7/15

主要3部門のヒアリングを反映。データ移行方式はこの時点で確定

開発完了

2027/1/10

計画から6ヶ月の開発期間。追加要望は次期フェーズへ切り出し済み

結合テスト開始

2027/2/5

本番相当のデータ量で3週間実施

結合テスト完了

2027/2/20

重大な不具合なし。ユーザ受入テストへ移行

本番移行判定

2027/3/10

移行可否を経営会議で最終判断

本稼働開始

2027/4/1

全部門で新システムに切り替え。旧システムは3ヶ月並行稼働後に廃止

こういうときに使う

  • プロジェクト・システム導入のロードマップ提示
  • 中期経営計画などのマイルストーン整理
  • 過去の意思決定・変遷の振り返り
  • 契約・稼働スケジュールの共有

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。結論を書くメッセージラインを推奨
subtitle
string(任意)— 補足・出典など
events
{ date: "YYYY-MM-DD", label, note?, highlight? } の配列。date の実経過時間に比例して横軸に自動配置(等間隔ではない)。note は図の下の補足欄で展開、highlight は最重要の1件のみ true

サンプルコード

template.tsx253 行
import { useId } from "react";
import type { TimelineProps } from "./schema";

const parseDate = (d: string) => new Date(d).getTime();

/**
 * 日付の実経過時間に比例して 0-100 の横軸位置を返す(等間隔ではない)。
 * 全イベントが同一日付(span=0)のときや、不正な日付文字列が1件でも
 * 混ざっているときも、その事象が他の正常なイベントの位置計算を巻き込んで
 * NaN にしないよう中央 50 に丸める(clampScore と同じ「壊れたデータで
 * レイアウト全体を壊さない」方針)。
 */
export const computePositions = (events: { date: string }[]): number[] => {
  const times = events.map((e) => parseDate(e.date));
  const validTimes = times.filter((t) => Number.isFinite(t));
  if (validTimes.length === 0) return times.map(() => 50);
  const min = Math.min(...validTimes);
  const max = Math.max(...validTimes);
  const span = max - min;
  return times.map((t) => {
    if (!Number.isFinite(t) || span === 0) return 50;
    return ((t - min) / span) * 100;
  });
};

/**
 * "YYYY-MM-DD" は日付のみの ISO 文字列として UTC 深夜0時にパースされるため、
 * 表示も UTC ゲッターで揃える(ローカル getter だと UTC より西のタイムゾーンで
 * 前日にずれる)。
 */
export const formatDate = (d: string) => {
  const date = new Date(d);
  return `${date.getUTCFullYear()}/${date.getUTCMonth() + 1}/${date.getUTCDate()}`;
};

/** highlight 要素専用グラデーション(DESIGN.md: indigo→violet 限定・1件のみ) */
const HIGHLIGHT_GRADIENT = "bg-linear-to-br from-tertiary to-accent-violet";

/**
 * ギャラリー側のTailwindはソーステキストを静的スキャンするため、テンプレート
 * リテラルで組み立てたクラス名(例: `bg-tertiary/${n}`)は検出されない。
 * 密度バケットは必ずこの完全なリテラル文字列配列から選ぶこと。
 */
const DENSITY_CLASSES = [
  "bg-tertiary/15",
  "bg-tertiary/35",
  "bg-tertiary/55",
  "bg-tertiary/80",
] as const;

/** ギャップの平均に対する比率が小さいほど「密=山場」として濃いクラスを返す */
const densityClass = (gap: number, meanGap: number): string => {
  const ratio = meanGap === 0 ? 1 : gap / meanGap;
  if (ratio <= 0.5) return DENSITY_CLASSES[3];
  if (ratio <= 1) return DENSITY_CLASSES[2];
  if (ratio <= 2) return DENSITY_CLASSES[1];
  return DENSITY_CLASSES[0];
};

/**
 * 隣接イベント間の実時間ギャップを4段階の濃淡セグメントに変換する
 * (DESIGN.md: 「計画の密度や山場」を可視化する信号)。日付順ではなく配列の
 * 生の順序が渡された場合でも、実位置でソートしてから隣接差分を取る
 * (未ソート入力で負のギャップ/逆向きセグメントを生まないため)。
 * イベント0〜1件、または全イベントが同一日付(span=0)の退化ケースでは
 * 空配列を返し、呼び出し側は平坦な基線表示にフォールバックする。
 */
export const densitySegments = (
  events: { date: string }[],
): { left: number; width: number; className: string }[] => {
  if (events.length < 2) return [];
  const sortedPositions = [...computePositions(events)].sort((a, b) => a - b);
  if (sortedPositions[sortedPositions.length - 1] === sortedPositions[0]) {
    return [];
  }
  const gaps = sortedPositions
    .slice(1)
    .map((pos, i) => pos - sortedPositions[i]);
  const meanGap = gaps.reduce((a, b) => a + b, 0) / gaps.length;
  return gaps.map((gap, i) => ({
    left: sortedPositions[i],
    width: gap,
    className: densityClass(gap, meanGap),
  }));
};

/** エントランスの時差。間隔は global.css の --stagger-step で統一 */
const stagger = (i: number, baseMs = 0) =>
  `calc(${baseMs}ms + ${i} * var(--stagger-step))`;

export default function Timeline({
  title,
  subtitle,
  events,
  instanceId,
}: TimelineProps) {
  const autoId = useId();
  const uid = instanceId ?? autoId;
  const notes = events.filter((event) => event.note);
  const positions = computePositions(events);
  const sorted = events
    .map((e, i) => ({ e, position: positions[i] }))
    .sort((a, b) => a.position - b.position)
    .map(({ e }) => e);
  const segments = densitySegments(events);

  return (
    <section
      data-timeline-root="true"
      className="group/timeline mx-auto max-w-slide bg-surface p-12 font-sans text-on-surface"
    >
      {/* 表示切替は CSS のみ(radio + :has())。成果物の単一ファイル制約を守る */}
      <input
        type="radio"
        name={uid}
        id={`${uid}-map`}
        className="v-map sr-only"
        defaultChecked
      />
      <input
        type="radio"
        name={uid}
        id={`${uid}-list`}
        className="v-list sr-only"
      />

      <header className="mb-8 flex flex-wrap items-start justify-between gap-4">
        <div>
          <h1 className="text-h1">{title}</h1>
          {subtitle && (
            <p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
          )}
        </div>
        <div className="inline-flex rounded-full border border-outline bg-surface p-1 shadow-card">
          <label
            htmlFor={`${uid}-map`}
            className="cursor-pointer rounded-full px-4 py-1.5 text-label text-secondary group-has-[.v-map:checked]/timeline:bg-primary group-has-[.v-map:checked]/timeline:text-on-primary group-has-[.v-map:focus-visible]/timeline:ring-2 group-has-[.v-map:focus-visible]/timeline:ring-tertiary"
          >
            年表
          </label>
          <label
            htmlFor={`${uid}-list`}
            className="cursor-pointer rounded-full px-4 py-1.5 text-label text-secondary group-has-[.v-list:checked]/timeline:bg-primary group-has-[.v-list:checked]/timeline:text-on-primary group-has-[.v-list:focus-visible]/timeline:ring-2 group-has-[.v-list:focus-visible]/timeline:ring-tertiary"
          >
            リスト
          </label>
        </div>
      </header>

      {/* ── 年表投影: 日付の実経過時間に比例した横軸配置 ── */}
      <div className="hidden group-has-[.v-map:checked]/timeline:block">
        <div className="relative mx-4 mt-20 mb-28 h-1.5">
          {segments.length > 0 ? (
            segments.map((seg, i) => (
              <div
                key={i}
                aria-hidden="true"
                className={`group/segment absolute inset-y-0 rounded-full ${seg.className}`}
                style={{ left: `${seg.left}%`, width: `${seg.width}%` }}
              />
            ))
          ) : (
            <div
              aria-hidden="true"
              className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-outline"
            />
          )}
          {events.map((e, i) => {
            const pos = positions[i];
            const above = i % 2 === 0;
            // 端に近いマーカーはカードが画面外にはみ出すため左右寄せに切り替える(matrix-2x2 と同じ対策)
            const cardAlign =
              pos >= 70
                ? "right-0"
                : pos <= 30
                  ? "left-0"
                  : "left-1/2 -translate-x-1/2";
            return (
              <div
                key={i}
                data-timeline-event={i}
                className="group/marker absolute top-1/2 -translate-x-1/2 -translate-y-1/2 animate-pop"
                style={{ left: `${pos}%`, animationDelay: stagger(i, 300) }}
              >
                <span
                  className={`block rounded-full shadow-card ${
                    e.highlight
                      ? `size-4.5 ring-4 ring-tertiary/25 ${HIGHLIGHT_GRADIENT}`
                      : "size-3 bg-tertiary ring-4 ring-tertiary/10"
                  }`}
                />
                <div
                  className={`absolute w-36 text-center ${cardAlign} ${above ? "bottom-full mb-3" : "top-full mt-3"}`}
                >
                  <p className="whitespace-nowrap text-label text-secondary">
                    {formatDate(e.date)}
                  </p>
                  <p className="mt-0.5 text-body-sm">{e.label}</p>
                </div>
              </div>
            );
          })}
        </div>
      </div>

      {/* ── リスト投影: 同じ events を日付順に束ねる ── */}
      <ol className="hidden space-y-3 group-has-[.v-list:checked]/timeline:block">
        {sorted.map((e, i) => (
          <li
            key={i}
            className="animate-rise flex items-start gap-4 rounded-lg border border-outline bg-surface-subtle p-5"
            style={{ animationDelay: stagger(i) }}
          >
            <span
              className={`mt-1 size-3 shrink-0 rounded-full ${
                e.highlight ? HIGHLIGHT_GRADIENT : "bg-tertiary/60"
              }`}
            />
            <div className="flex-1">
              <p className="text-label text-secondary">{formatDate(e.date)}</p>
              <p className="mt-1 text-body-sm">{e.label}</p>
              {e.note && (
                <p className="mt-1 text-body-sm text-secondary">{e.note}</p>
              )}
            </div>
          </li>
        ))}
      </ol>

      {notes.length > 0 && (
        <details
          data-inline-disclosure="true"
          className="group mt-6 rounded-lg border border-outline bg-surface-subtle"
        >
          <summary className="flex cursor-pointer list-none items-center px-4 py-3 text-label text-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary">
            <span>補足を確認</span>
            <span className="ml-auto font-mono">{notes.length}件</span>
            <span aria-hidden="true" className="ml-2 text-tertiary transition-transform group-open:rotate-45">+</span>
          </summary>
          <div className="grid gap-3 border-t border-outline p-4 sm:grid-cols-2">
            {notes.map((event, index) => (
              <article key={index} data-note-item="true" className="rounded-md bg-surface p-4">
                <h3 className="text-label">{event.label}</h3>
                <p className="mt-1 font-mono text-label text-secondary">{formatDate(event.date)}</p>
                <p className="mt-2 text-body-sm">{event.note}</p>
              </article>
            ))}
          </div>
        </details>
      )}
    </section>
  );
}
schema.ts39 行
import { z } from "zod";

export const timelineEventSchema = z
  .object({
    date: z.iso.date(),
    label: z
      .string()
      .describe("見出し。年表投影では常時表示、リスト投影の本文にもなる"),
    note: z.string().optional().describe("図の下の補足欄で明示的に展開される詳細"),
    highlight: z
      .boolean()
      .optional()
      .describe(
        "最重要の1件のみ true(グラデーション表示。DESIGN.md: 大胆さは1画面1箇所)",
      ),
  })
  .strict();

export const schema = z
  .object({
    title: z.string().describe("スライドタイトル。結論を書くメッセージラインを推奨"),
    subtitle: z.string().optional().describe("補足・出典など"),
    events: z.array(timelineEventSchema),
    instanceId: z
      .string()
      .optional()
      .describe("同一ページに複数インスタンスを置くときの radio グループ識別子"),
  })
  .strict()
  .refine(
    (data) => data.events.filter((e) => e.highlight === true).length <= 1,
    {
      message: "highlight は最大1件のみ true にすること(DESIGN.md: 大胆さは1画面1箇所)",
      path: ["events"],
    },
  );

export type TimelineEvent = z.infer<typeof timelineEventSchema>;
export type TimelineProps = z.infer<typeof schema>;
sample-data.json42 行
{
  "title": "基幹システム刷新ロードマップ — 本稼働まで残り3ヶ月",
  "subtitle": "2026年4月キックオフ〜2027年4月本稼働(2026年度 IT投資計画・サンプル)",
  "events": [
    {
      "date": "2026-04-01",
      "label": "要件定義キックオフ",
      "note": "現行業務フローの棚卸しと新システムのスコープ確定から着手"
    },
    {
      "date": "2026-07-15",
      "label": "基本設計完了",
      "note": "主要3部門のヒアリングを反映。データ移行方式はこの時点で確定"
    },
    {
      "date": "2027-01-10",
      "label": "開発完了",
      "note": "計画から6ヶ月の開発期間。追加要望は次期フェーズへ切り出し済み"
    },
    {
      "date": "2027-02-05",
      "label": "結合テスト開始",
      "note": "本番相当のデータ量で3週間実施"
    },
    {
      "date": "2027-02-20",
      "label": "結合テスト完了",
      "note": "重大な不具合なし。ユーザ受入テストへ移行"
    },
    {
      "date": "2027-03-10",
      "label": "本番移行判定",
      "note": "移行可否を経営会議で最終判断"
    },
    {
      "date": "2027-04-01",
      "label": "本稼働開始",
      "note": "全部門で新システムに切り替え。旧システムは3ヶ月並行稼働後に廃止",
      "highlight": true
    }
  ]
}