Back to index

Critical Path Gantt (Concurrent Plan)

Gantt
Generates documents
Critical Path Gantt (Concurrent Plan)

Seen as a design

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

critical-path-gantt-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

1.7 KB
Build an HTML document with "Critical Path Gantt (Concurrent Plan)". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
Tasks placed as real date ranges, with thin dependency lines from a bar's end to what it unblocks. The longest path is drawn as one continuous line, and at 390px the same graph transposes to a vertical ledger

## What you have to supply
- Each task's start and end date
- The dependencies between them
- Who owns each and how far along it is

## The shape of the input
- `title` — string — スライドタイトル。完了日を左右する重要経路を結論として書く
- `subtitle` — string(任意)— 対象期間・基準日・出典など
- `tasks` — { id, label, start, end, owner?, progress?, dependencies?, note? }[] — 3〜8件。日付で期間バーの位置と幅、dependenciesで接続線、期間合計で重要経路を導出

schema.ts holds the machine-checkable validation. Ask rather than guess when an input is missing.

## When not to use it
- Presenting only the milestone dates
- Explaining a serial procedure with no dependencies

## How to build it
Put the input in a JSON file and run:

```bash
node scripts/render.mjs critical-path-gantt <input.json> --theme ink -o out.html
```

The output is one self-contained file and fetches nothing at render time.

## The source
- https://design-vault.pages.dev/designs/critical-path-gantt/source/template.tsx
- https://design-vault.pages.dev/designs/critical-path-gantt/source/schema.ts
- https://design-vault.pages.dev/designs/critical-path-gantt/source/sample-data.json
- https://design-vault.pages.dev/designs/critical-path-gantt/sample.html

Files

Template, input schema, sample data and metadata. This document was generated from those four. The generated document

template.tsx

26.5 KB

/designs/critical-path-gantt/source/template.tsx

Show contents
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(
      // 捨てる操作は単射でないので、schema を通る別々の値が同じ id になる
      // (`sales:a` と `sales/a` がどちらも `salesa`)。同じ文書へ 2 つ置くと
      // id と radio の name が衝突して、片方の操作がもう片方に効く。
      // 逃がし文字(`_`)自身も逃がすので、この変換は単射。
      /[^a-zA-Z0-9-]/g,
      (c) => `_${c.charCodeAt(0).toString(16)}_`,
    ) || "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.ts

3.2 KB

/designs/critical-path-gantt/source/schema.ts

Show contents
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>;

meta.yaml

2.5 KB

/designs/critical-path-gantt/source/meta.yaml

Show contents
id: critical-path-gantt
name:
  ja: "クリティカルパス・ガント(並行計画)"
  en: "Critical Path Gantt (Concurrent Plan)"
purpose:
  ja: "タスクを実日付の期間として配置し、期間バー終端から依存先の開始点へ細い依存線を結ぶ。最長経路は全体計画上の連続線と期間比例laneで示し、390pxでは同じ依存graphを縦向きledgerへ転置する"
  en: "Tasks placed as real date ranges, with thin dependency lines from a bar's end to what it unblocks. The longest path is drawn as one continuous line, and at 390px the same graph transposes to a vertical ledger"
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: planning
catalogOrder: 60
jobs:
  - plan-sequence
  - prioritize-diagnose
useCases:
  - roadmap
layoutKinds:
  - gantt
tags: [gantt, planning, dependency, critical-path, data-driven, interactive]
whenToUse:
  - ja: "システム導入・移行計画で複数チームの並行作業を示す場合"
    en: "Showing several teams working in parallel on a rollout"
  - ja: "タスク間の依存関係と遅延影響を説明したい場合"
    en: "Explaining dependencies and what a slip would cost"
  - ja: "マイルストーンの点ではなく、期間・重なり・依存経路を見せたい場合"
    en: "Showing durations, overlaps and the dependency path rather than milestone points"
  - ja: "直列の業務手順ではなく、分岐・合流するプロジェクト計画を扱う場合"
    en: "A branching project plan rather than a serial procedure"
avoidWhen:
  - ja: "日付の節目だけを簡潔に示したい場合"
    en: "Presenting only the milestone dates"
  - ja: "依存関係のない直列手順を説明したい場合"
    en: "Explaining a serial procedure with no dependencies"
requiredInputs:
  - ja: "タスクの開始日と終了日"
    en: "Each task's start and end date"
  - ja: "タスク間の依存関係"
    en: "The dependencies between them"
  - ja: "担当と進捗"
    en: "Who owns each and how far along it is"
relatedLayouts: [timeline, process-flow, logic-tree]
dataSchema:
  title: "string — スライドタイトル。完了日を左右する重要経路を結論として書く"
  subtitle: "string(任意)— 対象期間・基準日・出典など"
  tasks: "{ id, label, start, end, owner?, progress?, dependencies?, note? }[] — 3〜8件。日付で期間バーの位置と幅、dependenciesで接続線、期間合計で重要経路を導出"
sources: []

sample-data.json

1.5 KB

/designs/critical-path-gantt/source/sample-data.json

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