要件定義キックオフ
2026/4/1
現行業務フローの棚卸しと新システムのスコープ確定から着手
型番 timeline
イベントを日付の実経過時間に比例して横軸に自動配置し、計画の密度や山場をデータ駆動で示す。補足は図の下で明示的に展開し、年表⇄リストを切り替えられる
timeline · roadmap · sequence · data-driven · interactive
LIVE SAMPLE
補足の開閉・表示切替を試せます
ALTERNATIVES
型番 critical-path-gantt
タスクを実日付の期間として配置し、期間バー終端から依存先の開始点へ細い依存線を結ぶ。最長経路は全体計画上の連続線と期間比例laneで示し、390pxでは同じ依存graphを縦向きledgerへ転置する
型番 process-flow
業務・システム導入の手順を一本の連続process laneとして描き、各工程の区間長を所要日数からデータ駆動で計算する。hand-off境界には開始からの累計日数を置き、hover・focusでは同じ工程の区間とレコードだけを穏やかに強調する。最も時間のかかる工程をlane上で自動強調し、補足は図の下で展開する
型番 waterfall
起点から終点までの増減要因を累計配置の棒(ブリッジ)で示す。PowerPointのネイティブ滝グラフが自動化する「累計配置」だけでは差別化にならないため、同じデータを寄与度順(|増減|降順)の横棒ランキングに切り替えられる点が核。数値は常時表示し、補足は図の下で展開する
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>
);
}
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>;
{
"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
}
]
}