型番 critical-path-gantt
クリティカルパス・ガント(並行計画)
タスクを実日付の期間として配置し、期間バー終端から依存先の開始点へ細い依存線を結ぶ。最長経路は全体計画上の連続線と期間比例laneで示し、390pxでは同じ依存graphを縦向きledgerへ転置する
gantt · planning · dependency · critical-path · data-driven · interactive
LIVE SAMPLE
プレビュー
補足の開閉・表示切替を試せます
こういうときに使う
- システム導入・移行計画で複数チームの並行作業を示す場合
- タスク間の依存関係と遅延影響を説明したい場合
- マイルストーンの点ではなく、期間・重なり・依存経路を見せたい場合
- 直列の業務手順ではなく、分岐・合流するプロジェクト計画を扱う場合
ALTERNATIVES
関連する型
型番 timeline
タイムライン(ロードマップ)
イベントを日付の実経過時間に比例して横軸に自動配置し、計画の密度や山場をデータ駆動で示す。補足は図の下で明示的に展開し、年表⇄リストを切り替えられる
型番 process-flow
プロセスフロー(業務手順)
業務・システム導入の手順を一本の連続process laneとして描き、各工程の区間長を所要日数からデータ駆動で計算する。hand-off境界には開始からの累計日数を置き、hover・focusでは同じ工程の区間とレコードだけを穏やかに強調する。最も時間のかかる工程をlane上で自動強調し、補足は図の下で展開する
型番 logic-tree
ロジックツリー(課題・KPIドライバー分解)
親IDで結んだ可変階層をD3のtidy treeで幹・枝・葉へ配置し、葉の寄与値を親へ自動集計して細い枝の線幅へ反映する。同じnode identityを構造から葉ドライバーの寄与度へ切り替え、構造では曲線・直線・直角の枝を閲覧者が選び、最大寄与の祖先経路を1件強調する
データスキーマ
- title
- string — スライドタイトル。完了日を左右する重要経路を結論として書く
- subtitle
- string(任意)— 対象期間・基準日・出典など
- tasks
- { id, label, start, end, owner?, progress?, dependencies?, note? }[] — 3〜8件。日付で期間バーの位置と幅、dependenciesで接続線、期間合計で重要経路を導出
サンプルコード
template.tsx711 行
import { useId } from "react";
import type { CriticalPathGanttProps, GanttTask } from "./schema";
const DAY = 86_400_000;
const DESKTOP_ROW_HEIGHT = 44;
const DESKTOP_ROW_GAP = 8;
const DESKTOP_ROW_PITCH = DESKTOP_ROW_HEIGHT + DESKTOP_ROW_GAP;
const MOBILE_ROW_HEIGHT = 64;
const MOBILE_ROW_GAP = 12;
const MOBILE_ROW_PITCH = MOBILE_ROW_HEIGHT + MOBILE_ROW_GAP;
const time = (date: string) => new Date(date).getTime();
export function durationDays(task: Pick<GanttTask, "start" | "end">) {
return Math.max(1, Math.round((time(task.end) - time(task.start)) / DAY) + 1);
}
export function scheduleGeometry(
task: GanttTask,
tasks: readonly GanttTask[],
) {
const min = Math.min(...tasks.map((item) => time(item.start)));
const max = Math.max(...tasks.map((item) => time(item.end)));
const totalDays = Math.max(1, Math.round((max - min) / DAY) + 1);
return {
leftPct: (Math.round((time(task.start) - min) / DAY) / totalDays) * 100,
widthPct: (durationDays(task) / totalDays) * 100,
};
}
export function criticalPathIds(tasks: readonly GanttTask[]): string[] {
const byId = new Map(tasks.map((task) => [task.id, task]));
const memo = new Map<string, { ids: string[]; duration: number }>();
const pathTo = (id: string): { ids: string[]; duration: number } => {
const cached = memo.get(id);
if (cached) return cached;
const task = byId.get(id);
if (!task) return { ids: [], duration: 0 };
let best = { ids: [] as string[], duration: 0 };
for (const dependency of task.dependencies ?? []) {
const candidate = pathTo(dependency);
if (candidate.duration > best.duration) best = candidate;
}
const result = {
ids: [...best.ids, id],
duration: best.duration + durationDays(task),
};
memo.set(id, result);
return result;
};
let best = { ids: [] as string[], duration: 0 };
for (const task of tasks) {
const candidate = pathTo(task.id);
if (candidate.duration > best.duration) best = candidate;
}
return best.ids;
}
export type GanttDependencyEdge = {
index: number;
sourceId: string;
targetId: string;
sourceIndex: number;
targetIndex: number;
sourceEndPct: number;
targetStartPct: number;
isCritical: boolean;
};
/**
* dependencyを、期間バー端点と行位置を共有するedgeへ変換する。
* 最長経路は「criticalなtask集合」ではなく、経路内で連続するedgeだけを判定する。
*/
export function dependencyEdges(
tasks: readonly GanttTask[],
): GanttDependencyEdge[] {
const critical = criticalPathIds(tasks);
const criticalPairs = new Set(
critical.slice(1).map((id, index) => `${critical[index]}→${id}`),
);
const indexById = new Map(tasks.map((task, index) => [task.id, index]));
const edges: GanttDependencyEdge[] = [];
tasks.forEach((target, targetIndex) => {
for (const sourceId of target.dependencies ?? []) {
const sourceIndex = indexById.get(sourceId);
if (sourceIndex === undefined) continue;
const sourceGeometry = scheduleGeometry(tasks[sourceIndex], tasks);
const targetGeometry = scheduleGeometry(target, tasks);
edges.push({
index: edges.length,
sourceId,
targetId: target.id,
sourceIndex,
targetIndex,
sourceEndPct: sourceGeometry.leftPct + sourceGeometry.widthPct,
targetStartPct: targetGeometry.leftPct,
isCritical: criticalPairs.has(`${sourceId}→${target.id}`),
});
}
});
return edges;
}
const formatDate = (date: string) => {
const parsed = new Date(date);
return `${parsed.getUTCMonth() + 1}/${parsed.getUTCDate()}`;
};
const desktopDependencyPath = (
edge: GanttDependencyEdge,
edgeIndex: number,
) => {
const sourceX = edge.sourceEndPct * 10;
const targetX = edge.targetStartPct * 10;
const sourceY =
edge.sourceIndex * DESKTOP_ROW_PITCH + DESKTOP_ROW_HEIGHT / 2;
const targetY =
edge.targetIndex * DESKTOP_ROW_PITCH + DESKTOP_ROW_HEIGHT / 2;
const bendX =
targetX - sourceX > 28
? sourceX + (targetX - sourceX) * 0.5
: Math.min(992, Math.max(sourceX, targetX) + 24 + (edgeIndex % 3) * 12);
return `M ${sourceX} ${sourceY} H ${bendX} V ${targetY} H ${targetX}`;
};
const mobileDependencyPath = (
edge: GanttDependencyEdge,
edgeIndex: number,
) => {
const sourceY =
edge.sourceIndex * MOBILE_ROW_PITCH + MOBILE_ROW_HEIGHT / 2;
const targetY =
edge.targetIndex * MOBILE_ROW_PITCH + MOBILE_ROW_HEIGHT / 2;
const channelX = 5 + (edgeIndex % 4) * 6;
return `M 38 ${sourceY} H ${channelX} V ${targetY} H 38`;
};
const criticalLaneGeometry = (tasks: readonly GanttTask[]) => {
const total = Math.max(
1,
tasks.reduce((sum, task) => sum + durationDays(task), 0),
);
let elapsed = 0;
return tasks.map((task) => {
const days = durationDays(task);
const startPct = (elapsed / total) * 100;
elapsed += days;
return {
startPct,
widthPct: (days / total) * 100,
days,
cumulativeDays: elapsed,
};
});
};
const interactionCss = (
tasks: readonly GanttTask[],
edges: readonly GanttDependencyEdge[],
terminalIndex: number,
) => {
const taskRules = tasks
.map(
(_, index) => `
[data-gantt-root]:has([data-gantt-hotspot="${index}"]:is(:hover, :focus))
[data-gantt-record="${index}"] {
color: var(--color-on-surface);
opacity: 1;
}
[data-gantt-root]:has([data-gantt-hotspot="${index}"]:is(:hover, :focus))
[data-gantt-bar="${index}"] {
background-color: var(--color-on-surface);
background-image: none;
color: var(--color-surface);
opacity: 1;
}
[data-gantt-root]:has([data-gantt-hotspot="${index}"]:is(:hover, :focus))
:is([data-source-index="${index}"], [data-target-index="${index}"]) {
stroke: var(--color-on-surface);
stroke-opacity: 1;
}
${
index === terminalIndex
? ""
: `
[data-gantt-root]:has([data-gantt-hotspot="${index}"]:is(:hover, :focus))
[data-gantt-priority="true"] {
opacity: 0.34;
}`
}
`,
)
.join("");
const edgeRules = edges
.map(
(edge) => `
[data-gantt-root]:has([data-gantt-edge-hotspot="${edge.index}"]:is(:hover, :focus))
[data-gantt-dependency="${edge.index}"] {
stroke: var(--color-on-surface);
stroke-opacity: 1;
}
[data-gantt-root]:has([data-gantt-edge-hotspot="${edge.index}"]:is(:hover, :focus))
:is([data-gantt-record="${edge.sourceIndex}"], [data-gantt-record="${edge.targetIndex}"]) {
color: var(--color-on-surface);
opacity: 1;
}
[data-gantt-root]:has([data-gantt-edge-hotspot="${edge.index}"]:is(:hover, :focus))
:is([data-gantt-bar="${edge.sourceIndex}"], [data-gantt-bar="${edge.targetIndex}"]) {
background-color: var(--color-on-surface);
background-image: none;
color: var(--color-surface);
opacity: 1;
}
`,
)
.join("");
return `
[data-gantt-root] :is(
[data-gantt-record],
[data-gantt-bar],
[data-gantt-dependency],
[data-gantt-priority]
) {
transition:
color 140ms ease,
background-color 140ms ease,
opacity 140ms ease,
stroke 140ms ease,
stroke-opacity 140ms ease;
}
${taskRules}
${edgeRules}
`;
};
type DependencyPathsProps = {
edges: readonly GanttDependencyEdge[];
markerId: string;
mobile?: boolean;
};
function DependencyPaths({
edges,
markerId,
mobile = false,
}: DependencyPathsProps) {
return (
<>
<defs>
<marker
id={markerId}
viewBox="0 0 6 6"
refX="5"
refY="3"
markerWidth="5"
markerHeight="5"
orient="auto"
>
<path d="M 0 0 L 6 3 L 0 6 z" className="fill-outline" />
</marker>
</defs>
{edges.map((edge) => {
const path = mobile
? mobileDependencyPath(edge, edge.index)
: desktopDependencyPath(edge, edge.index);
return (
<g key={`${mobile ? "m" : "d"}-${edge.sourceId}-${edge.targetId}`}>
<path
data-gantt-dependency={edge.index}
data-source-id={edge.sourceId}
data-target-id={edge.targetId}
data-source-index={edge.sourceIndex}
data-target-index={edge.targetIndex}
data-source-end-pct={edge.sourceEndPct}
data-target-start-pct={edge.targetStartPct}
data-critical-edge={edge.isCritical ? "true" : "false"}
d={path}
fill="none"
markerEnd={`url(#${markerId})`}
vectorEffect="non-scaling-stroke"
className={
edge.isCritical
? "stroke-tertiary [stroke-opacity:.86] [stroke-width:1.8]"
: "stroke-outline [stroke-opacity:.48] [stroke-width:1.15]"
}
/>
<path
data-gantt-edge-hotspot={edge.index}
tabIndex={0}
aria-label={`${edge.sourceId}から${edge.targetId}への依存`}
d={path}
fill="none"
stroke="transparent"
vectorEffect="non-scaling-stroke"
className="cursor-default [pointer-events:stroke] [stroke-width:12] focus-visible:outline-none"
/>
</g>
);
})}
</>
);
}
export default function CriticalPathGantt({
title,
subtitle,
tasks,
instanceId,
}: CriticalPathGanttProps) {
const autoId = useId();
const uid = (instanceId ?? autoId).replace(/[^a-zA-Z0-9_-]/g, "") || "gantt";
const critical = criticalPathIds(tasks);
const criticalSet = new Set(critical);
const terminalId = critical.at(-1);
const terminalIndex = tasks.findIndex((task) => task.id === terminalId);
const criticalTasks = critical
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is GanttTask => Boolean(task));
const criticalGeometry = criticalLaneGeometry(criticalTasks);
const totalCriticalDays = criticalGeometry.at(-1)?.cumulativeDays ?? 0;
const edges = dependencyEdges(tasks);
const notes = tasks.filter((task) => task.note);
const start = tasks.reduce(
(min, task) => (task.start < min ? task.start : min),
tasks[0]?.start ?? "",
);
const end = tasks.reduce(
(max, task) => (task.end > max ? task.end : max),
tasks[0]?.end ?? "",
);
const desktopHeight = Math.max(
0,
tasks.length * DESKTOP_ROW_PITCH - DESKTOP_ROW_GAP,
);
const mobileHeight = Math.max(
0,
tasks.length * MOBILE_ROW_PITCH - MOBILE_ROW_GAP,
);
return (
<section
data-gantt-root="true"
className="group/gantt mx-auto max-w-slide bg-surface p-6 font-sans text-on-surface sm:p-12"
>
<style>{interactionCss(tasks, edges, terminalIndex)}</style>
<input
id={`${uid}-schedule`}
name={`${uid}-view`}
type="radio"
defaultChecked
className="gantt-schedule sr-only"
/>
<input
id={`${uid}-critical`}
name={`${uid}-view`}
type="radio"
className="gantt-critical sr-only"
/>
<header className="mb-7 flex flex-wrap items-end justify-between gap-4">
<div className="max-w-3xl">
<h1 className="text-h1">{title}</h1>
{subtitle && (
<p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
)}
</div>
<div className="flex rounded-sm border border-outline bg-surface-subtle p-1 shadow-card">
<label
htmlFor={`${uid}-schedule`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.gantt-schedule:checked]/gantt:bg-primary group-has-[.gantt-schedule:checked]/gantt:text-on-primary"
>
全体計画
</label>
<label
htmlFor={`${uid}-critical`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.gantt-critical:checked]/gantt:bg-primary group-has-[.gantt-critical:checked]/gantt:text-on-primary"
>
重要経路
</label>
</div>
</header>
<div
data-testid="gantt-schedule"
className="block group-has-[.gantt-critical:checked]/gantt:hidden"
>
<div
data-gantt-schedule="desktop"
className="hidden sm:block"
>
<div className="mb-3 grid grid-cols-[9rem_1fr] items-center gap-4 font-mono text-label text-secondary">
<span>WORKSTREAM</span>
<span className="flex justify-between">
<span>{formatDate(start)}</span>
<span>{formatDate(end)}</span>
</span>
</div>
<div className="relative">
<svg
aria-label={`${edges.length}件の依存関係`}
viewBox={`0 0 1000 ${desktopHeight}`}
preserveAspectRatio="none"
className="absolute bottom-0 right-0 top-0 z-20 w-[calc(100%-10rem)] overflow-visible"
>
<DependencyPaths edges={edges} markerId={`${uid}-desktop-arrow`} />
</svg>
<div className="grid gap-y-2">
{tasks.map((task, index) => {
const geometry = scheduleGeometry(task, tasks);
const isCritical = criticalSet.has(task.id);
const isTerminal = task.id === terminalId;
const barValue =
task.progress === undefined
? `${durationDays(task)}d`
: `${task.progress}%`;
const compactBar = geometry.widthPct < 8;
return (
<article
key={task.id}
data-gantt-task-id={task.id}
data-critical={isCritical ? "true" : "false"}
className="grid h-11 grid-cols-[9rem_1fr] items-center gap-4"
>
<button
type="button"
data-gantt-record={index}
data-gantt-hotspot={index}
className={`min-w-0 cursor-default text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary ${
isTerminal
? "text-tertiary"
: isCritical
? "text-on-surface"
: "text-secondary"
}`}
>
<span className="block truncate text-label">
{task.label}
</span>
<span className="block truncate font-mono text-label opacity-70">
{task.owner ?? `${durationDays(task)}日`}
{compactBar ? ` · ${barValue}` : ""}
</span>
</button>
<div
data-gantt-track="true"
className="relative h-11 bg-surface-subtle"
>
<span
className="absolute inset-y-0 left-1/4 border-l border-outline"
aria-hidden="true"
/>
<span
className="absolute inset-y-0 left-1/2 border-l border-outline"
aria-hidden="true"
/>
<span
className="absolute inset-y-0 left-3/4 border-l border-outline"
aria-hidden="true"
/>
<button
type="button"
data-gantt-bar={index}
data-gantt-hotspot={index}
data-gantt-priority={isTerminal ? "true" : undefined}
aria-label={`${task.label}: ${barValue}`}
className={`absolute inset-y-1 z-10 flex cursor-default items-center overflow-hidden px-2 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-on-surface ${
isTerminal
? "bg-gradient-to-r from-tertiary to-accent-violet text-on-primary"
: isCritical
? "bg-tertiary text-on-primary"
: "bg-neutral text-secondary"
}`}
style={{
left: `${geometry.leftPct}%`,
width: `${geometry.widthPct}%`,
}}
>
{!compactBar && (
<span className="truncate font-mono text-label">
{barValue}
</span>
)}
</button>
</div>
</article>
);
})}
</div>
</div>
<p className="mt-4 text-right text-label text-secondary">
線が工程間の依存、濃い線が最長経路。終点だけをグラデーションで表示
</p>
</div>
<div data-gantt-schedule="mobile" className="sm:hidden">
<div className="mb-3 flex items-center justify-between font-mono text-label text-secondary">
<span>DEPENDENCY LEDGER</span>
<span>
{formatDate(start)}–{formatDate(end)}
</span>
</div>
<div className="relative" style={{ height: mobileHeight }}>
<svg
aria-label={`${edges.length}件の依存関係`}
viewBox={`0 0 40 ${mobileHeight}`}
preserveAspectRatio="none"
className="absolute inset-y-0 left-0 z-20 w-10 overflow-visible"
>
<DependencyPaths
edges={edges}
markerId={`${uid}-mobile-arrow`}
mobile
/>
</svg>
<div className="ml-12 grid gap-y-3">
{tasks.map((task, index) => {
const geometry = scheduleGeometry(task, tasks);
const isCritical = criticalSet.has(task.id);
const isTerminal = task.id === terminalId;
return (
<article
key={task.id}
data-gantt-task-id={task.id}
data-critical={isCritical ? "true" : "false"}
className="relative h-16 border-b border-outline"
>
<button
type="button"
data-gantt-record={index}
data-gantt-hotspot={index}
className={`grid h-full w-full cursor-default grid-cols-[minmax(0,1fr)_auto] content-center gap-x-2 pr-1 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary ${
isTerminal
? "text-tertiary"
: isCritical
? "text-on-surface"
: "text-secondary"
}`}
>
<span className="truncate text-label">{task.label}</span>
<span className="font-mono text-label">
{durationDays(task)}日
</span>
<span className="truncate font-mono text-label opacity-65">
{task.owner ?? "担当未設定"}
</span>
<span className="font-mono text-label opacity-65">
{formatDate(task.start)}–{formatDate(task.end)}
</span>
</button>
<span className="absolute inset-x-0 bottom-0 h-1 bg-surface-subtle">
<span
data-gantt-bar={index}
data-gantt-priority={isTerminal ? "true" : undefined}
className={`absolute inset-y-0 ${
isTerminal
? "bg-gradient-to-r from-tertiary to-accent-violet"
: isCritical
? "bg-tertiary"
: "bg-neutral"
}`}
style={{
left: `${geometry.leftPct}%`,
width: `${geometry.widthPct}%`,
}}
/>
</span>
</article>
);
})}
</div>
</div>
<p className="mt-4 text-label text-secondary">
左の細線が先行工程からの依存。下線の位置と長さは全体日程に比例
</p>
</div>
</div>
<div
data-testid="gantt-critical"
className="hidden group-has-[.gantt-critical:checked]/gantt:block"
>
<div className="flex items-end justify-between gap-4 border-y border-outline py-4">
<div>
<p className="font-mono text-label text-tertiary">CRITICAL PATH</p>
<p className="mt-1 text-h3">
{criticalTasks.map((task) => task.label).join(" → ")}
</p>
</div>
<p className="shrink-0 font-mono text-h2">{totalCriticalDays}日</p>
</div>
<div
data-gantt-critical-lane="true"
className="relative mt-7 flex h-20 overflow-hidden border-y border-outline"
>
{criticalTasks.map((task, index) => {
const geometry = criticalGeometry[index];
const isTerminal = index === criticalTasks.length - 1;
return (
<button
type="button"
key={task.id}
data-gantt-critical-segment={task.id}
data-priority={isTerminal ? "true" : "false"}
className={`relative min-w-0 cursor-default border-r border-surface px-2 text-left last:border-r-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-on-surface ${
isTerminal
? "bg-gradient-to-r from-tertiary to-accent-violet text-on-primary"
: index === 0
? "bg-tertiary text-on-primary"
: "bg-tertiary-container text-on-tertiary-container"
}`}
style={{ width: `${geometry.widthPct}%` }}
>
{geometry.widthPct >= 10 && (
<>
<span className="block truncate text-label">
{task.label}
</span>
<span className="mt-1 block font-mono text-label opacity-70">
{geometry.days}日
</span>
</>
)}
</button>
);
})}
{criticalGeometry.slice(0, -1).map((geometry, index) => (
<span
key={index}
data-gantt-critical-boundary={index}
data-cumulative-days={geometry.cumulativeDays}
aria-label={`累計${geometry.cumulativeDays}日`}
className="pointer-events-none absolute inset-y-0 border-r border-surface/75"
style={{
left: `${geometry.startPct + geometry.widthPct}%`,
}}
/>
))}
</div>
<ol className="mt-6 border-y border-outline">
{criticalTasks.map((task, index) => (
<li
key={task.id}
className="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-center gap-3 border-b border-outline py-3 last:border-b-0"
>
<span className="font-mono text-label text-secondary">
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0">
<span className="block truncate text-label">{task.label}</span>
<span className="block truncate font-mono text-label text-secondary">
{formatDate(task.start)}–{formatDate(task.end)} ・{" "}
{task.owner ?? "担当未設定"}
</span>
</span>
<span className="text-right font-mono text-label">
{durationDays(task)}日
<span className="block text-secondary">
累計{criticalGeometry[index].cumulativeDays}日
</span>
</span>
</li>
))}
</ol>
</div>
{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((task) => (
<article
key={task.id}
data-note-item="true"
className="rounded-md bg-surface p-4"
>
<h3 className="text-label">{task.label}</h3>
<p className="mt-1 font-mono text-label text-secondary">
{formatDate(task.start)}–{formatDate(task.end)} ・{" "}
{task.owner ?? "担当未設定"} ・ {durationDays(task)}日
</p>
<p className="mt-2 text-body-sm">{task.note}</p>
</article>
))}
</div>
</details>
)}
</section>
);
}
schema.ts71 行
import { z } from "zod";
export const ganttTaskSchema = z
.object({
id: z.string().min(1).describe("依存関係から参照する一意なタスクID"),
label: z.string().min(1).describe("タスク・工程名"),
start: z.iso.date().describe("開始日(YYYY-MM-DD)"),
end: z.iso.date().describe("終了日(YYYY-MM-DD、開始日以降)"),
owner: z.string().optional().describe("担当チーム・責任者"),
progress: z.number().min(0).max(100).optional().describe("進捗率(0〜100)"),
dependencies: z.array(z.string().min(1)).optional().describe("先行タスクIDの配列"),
note: z.string().optional().describe("前提、遅延要因、完了条件などの補足"),
})
.strict();
export const schema = z
.object({
title: z.string().describe("スライドタイトル。重要経路と判断を結論として書く"),
subtitle: z.string().optional().describe("対象期間、基準日、出典など"),
tasks: z.array(ganttTaskSchema).min(3).max(8).describe("計画タスク(3〜8件)"),
instanceId: z.string().min(1).optional().describe("表示切替radioの識別子"),
})
.strict()
.superRefine((data, ctx) => {
const ids = new Set<string>();
data.tasks.forEach((task, index) => {
if (ids.has(task.id)) {
ctx.addIssue({ code: "custom", message: "task.id は一意にすること", path: ["tasks", index, "id"] });
}
ids.add(task.id);
if (new Date(task.end).getTime() < new Date(task.start).getTime()) {
ctx.addIssue({ code: "custom", message: "end は start 以降にすること", path: ["tasks", index, "end"] });
}
});
const taskIds = new Set(data.tasks.map((task) => task.id));
data.tasks.forEach((task, index) => {
const seen = new Set<string>();
for (const dependency of task.dependencies ?? []) {
if (!taskIds.has(dependency)) {
ctx.addIssue({ code: "custom", message: `未知の依存先: ${dependency}`, path: ["tasks", index, "dependencies"] });
}
if (dependency === task.id) {
ctx.addIssue({ code: "custom", message: "自分自身へ依存できない", path: ["tasks", index, "dependencies"] });
}
if (seen.has(dependency)) {
ctx.addIssue({ code: "custom", message: "同じ依存先を重複指定できない", path: ["tasks", index, "dependencies"] });
}
seen.add(dependency);
}
});
const byId = new Map(data.tasks.map((task) => [task.id, task]));
const visiting = new Set<string>();
const visited = new Set<string>();
const hasCycle = (id: string): boolean => {
if (visiting.has(id)) return true;
if (visited.has(id)) return false;
visiting.add(id);
const cyclic = (byId.get(id)?.dependencies ?? []).some((dep) => byId.has(dep) && hasCycle(dep));
visiting.delete(id);
visited.add(id);
return cyclic;
};
if (data.tasks.some((task) => hasCycle(task.id))) {
ctx.addIssue({ code: "custom", message: "dependencies に循環を作れない", path: ["tasks"] });
}
});
export type GanttTask = z.infer<typeof ganttTaskSchema>;
export type CriticalPathGanttProps = z.infer<typeof schema>;
sample-data.json12 行
{
"title": "基幹刷新計画 — 設計・開発・UATの連鎖が本番日を左右する",
"subtitle": "2026年度 基幹システム刷新計画(暦日ベース)",
"tasks": [
{ "id": "requirements", "label": "要件定義", "start": "2026-04-01", "end": "2026-04-14", "owner": "業務改革室", "progress": 100, "note": "対象業務42プロセスのTo-Be要件を確定" },
{ "id": "design", "label": "基本設計", "start": "2026-04-15", "end": "2026-05-08", "owner": "IT企画", "progress": 80, "dependencies": ["requirements"], "note": "主要IF 18本と権限モデルを設計" },
{ "id": "build", "label": "開発・単体試験", "start": "2026-05-11", "end": "2026-06-19", "owner": "開発ベンダー", "progress": 45, "dependencies": ["design"], "note": "重要経路。遅延1日がUAT開始へ直結" },
{ "id": "migration", "label": "移行準備", "start": "2026-04-27", "end": "2026-06-05", "owner": "データ移行班", "progress": 60, "dependencies": ["requirements"], "note": "マスタ12種のクレンジングと移行リハーサル" },
{ "id": "uat", "label": "UAT", "start": "2026-06-22", "end": "2026-07-10", "owner": "業務部門", "progress": 0, "dependencies": ["build", "migration"], "note": "主要シナリオ86件を業務部門が検証" },
{ "id": "cutover", "label": "本番切替", "start": "2026-07-13", "end": "2026-07-15", "owner": "統合PMO", "progress": 0, "dependencies": ["uat"], "note": "停止許容時間6時間以内で切替" }
]
}