BizLayout

型番 critical-path-gantt

クリティカルパス・ガント(並行計画)

タスクを実日付の期間として配置し、期間バー終端から依存先の開始点へ細い依存線を結ぶ。最長経路は全体計画上の連続線と期間比例laneで示し、390pxでは同じ依存graphを縦向きledgerへ転置する

gantt · planning · dependency · critical-path · data-driven · interactive

LIVE SAMPLE

プレビュー

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

基幹刷新計画 — 設計・開発・UATの連鎖が本番日を左右する

2026年度 基幹システム刷新計画(暦日ベース)

DEPENDENCY LEDGER4/17/15

左の細線が先行工程からの依存。下線の位置と長さは全体日程に比例

補足を確認6

要件定義

4/14/14 業務改革室14

対象業務42プロセスのTo-Be要件を確定

基本設計

4/155/8 IT企画24

主要IF 18本と権限モデルを設計

開発・単体試験

5/116/19 開発ベンダー40

重要経路。遅延1日がUAT開始へ直結

移行準備

4/276/5 データ移行班40

マスタ12種のクレンジングと移行リハーサル

UAT

6/227/10 業務部門19

主要シナリオ86件を業務部門が検証

本番切替

7/137/15 統合PMO3

停止許容時間6時間以内で切替

こういうときに使う

  • システム導入・移行計画で複数チームの並行作業を示す場合
  • タスク間の依存関係と遅延影響を説明したい場合
  • マイルストーンの点ではなく、期間・重なり・依存経路を見せたい場合
  • 直列の業務手順ではなく、分岐・合流するプロジェクト計画を扱う場合

ALTERNATIVES

条件を変えて探す →

データスキーマ

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時間以内で切替" }
  ]
}