索引に戻る

プロセスフロー(業務手順)

フロー
生成可能
プロセスフロー(業務手順)

作品として見る

持ち帰る

コピーして貼るか、ファイルに保存して渡す。ネットにつながらない環境には「全部入り」を 1 つ渡せば足りる。

全部入り

process-flow-everything.md

このパネルの中身を、URL の先にあるファイルの中身まで含めて 1 つにまとめた Markdown

依頼文

1.7 KB
「プロセスフロー(業務手順)」でHTML資料を作ってください。この型は実行できるので、版面を組み直す必要はありません — 必要なのは中身です。

## この型がすること
所要時間の比率で連続 lane の区間長を自動計算し、最も時間を食う工程を lane 上で強調する。どこが詰まっているかが長さで分かる

## 渡す必要があるもの
- 工程名
- 各工程の所要時間
- 担当者または補足

## 入力の形
- `title` — string — スライドタイトル。結論を書くメッセージラインを推奨
- `subtitle` — string(任意)— 補足・出典など
- `steps` — { label, weight, owner?, note? } の配列。weight(所要日数等)の比率で連続 lane の区間長を自動計算し、最大の工程を lane 上で強調表示。owner/note は図の下の補足欄で展開

入力の機械可読な検証は schema.ts が持ちます。足りない入力は推測せず質問してください。

## 向かない場合
- 分岐と合流を含む多対多の流量を示したい場合
- 並行タスクと依存関係から完了日への影響を説明したい場合

## 作り方
入力を JSON にまとめ、次を実行します。

```bash
node scripts/render.mjs process-flow <入力.json> --theme ink -o out.html
```

出力は 1 ファイルで完結し、外部から何も読み込みません。

## 原本
- https://design-vault.pages.dev/designs/process-flow/source/template.tsx
- https://design-vault.pages.dev/designs/process-flow/source/schema.ts
- https://design-vault.pages.dev/designs/process-flow/source/sample-data.json
- https://design-vault.pages.dev/designs/process-flow/sample.html

ファイル

テンプレート・入力スキーマ・サンプルデータ・メタ情報。この文書はこの 4 つから生成されています。 生成された文書

template.tsx

15.6 KB

/designs/process-flow/source/template.tsx

中身を見る
import { useId } from "react";
import type { ProcessFlowProps } from "./schema";

/** weight に比例した区間幅。0以下は可視性を保つ最小値へクランプする。 */
export const stepWidths = (steps: { weight: number }[]): number[] =>
  steps.map((step) => Math.max(0.1, step.weight));

/** 最も weight が大きい工程のindex。同値は先頭を採用する。 */
export const bottleneckIndex = (steps: { weight: number }[]): number => {
  let maxIndex = 0;
  for (let index = 1; index < steps.length; index++) {
    if (steps[index].weight > steps[maxIndex].weight) maxIndex = index;
  }
  return maxIndex;
};

/**
 * 各工程の中央位置(%)。クランプ済み区間幅の累積から求める。
 * ラベル、connector、操作対象を同じ工程断面へ揃えるために使う。
 */
export const stepCenters = (steps: { weight: number }[]): number[] => {
  const widths = stepWidths(steps);
  const total = widths.reduce((sum, width) => sum + width, 0);
  let elapsed = 0;
  return widths.map((width) => {
    const start = elapsed;
    elapsed += width;
    return ((start + elapsed) / 2 / total) * 100;
  });
};

/**
 * 各hand-off時点の生weight累計。クランプ値は描画だけに使い、
 * 表示する日数へ混ぜない。
 */
export const cumulativeDays = (steps: { weight: number }[]): number[] => {
  const result: number[] = [];
  let elapsed = 0;
  for (let index = 0; index < steps.length - 1; index++) {
    elapsed += steps[index].weight;
    result.push(elapsed);
  }
  return result;
};

export type ProcessLaneSegment = {
  originalIndex: number;
  startPct: number;
  endPct: number;
  widthPct: number;
  centerPct: number;
  cumulativeDays: number;
};

/**
 * 独立カードの幅ではなく、一本のprocess lane上の連続区間へ工程を写す。
 * endPctは必ず次工程のstartPctになり、最終工程は100%で終わる。
 */
export function processLaneGeometry(
  steps: readonly { weight: number }[],
): ProcessLaneSegment[] {
  const widths = stepWidths([...steps]);
  const totalWidth = widths.reduce((sum, width) => sum + width, 0);
  let widthElapsed = 0;
  let daysElapsed = 0;

  return widths.map((width, originalIndex) => {
    const startPct = (widthElapsed / totalWidth) * 100;
    widthElapsed += width;
    daysElapsed += steps[originalIndex].weight;
    const endPct = (widthElapsed / totalWidth) * 100;
    return {
      originalIndex,
      startPct,
      endPct,
      widthPct: endPct - startPct,
      centerPct: (startPct + endPct) / 2,
      cumulativeDays: daysElapsed,
    };
  });
}

const HIGHLIGHT_GRADIENT = "bg-linear-to-br from-tertiary to-accent-violet";
const stagger = (index: number) =>
  `calc(var(--stagger-step) * ${index})`;

const formatDays = (value: number) =>
  new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 1 }).format(value);

export default function ProcessFlow({
  title,
  subtitle,
  steps,
}: ProcessFlowProps) {
  const autoId = useId();
  const uid = autoId.replace(
      // 捨てる操作は単射でないので、schema を通る別々の値が同じ id になる
      // (`sales:a` と `sales/a` がどちらも `salesa`)。同じ文書へ 2 つ置くと
      // id と radio の name が衝突して、片方の操作がもう片方に効く。
      // 逃がし文字(`_`)自身も逃がすので、この変換は単射。
      /[^a-zA-Z0-9-]/g,
      (c) => `_${c.charCodeAt(0).toString(16)}_`,
    ) || "process";
  const geometry = processLaneGeometry(steps);
  const bottleneck = steps.length > 0 ? bottleneckIndex(steps) : -1;
  const totalDays = steps.reduce((sum, step) => sum + step.weight, 0);
  const notes = steps.filter((step) => step.note);

  const interactionStyles = `
[data-process-instance="${uid}"] [data-process-segment] {
  transition: background-color 140ms ease-out, color 140ms ease-out;
}
[data-process-instance="${uid}"] [data-process-record],
[data-process-instance="${uid}"] [data-process-connector],
[data-process-instance="${uid}"] [data-process-boundary] {
  transition: color 140ms ease-out, border-color 140ms ease-out, opacity 140ms ease-out;
}
[data-process-instance="${uid}"] [data-process-hotspot]:focus-visible {
  outline: none;
}
${geometry
  .map(
    (segment) => `
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-segment][data-step-index="${segment.originalIndex}"]:not([data-priority]) {
  background-color: var(--color-tertiary-container);
  color: var(--color-on-tertiary-container);
}
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-record][data-step-index="${segment.originalIndex}"] {
  color: var(--color-tertiary);
}
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-connector][data-step-index="${segment.originalIndex}"],
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-boundary][data-step-index="${segment.originalIndex}"] {
  border-color: var(--color-tertiary);
  opacity: 1;
}`,
  )
  .join("")}
`;

  return (
    <section
      data-process-instance={uid}
      className="mx-auto max-w-slide bg-surface p-6 font-sans text-on-surface sm:p-12"
    >
      <style>{interactionStyles}</style>
      <header className="mb-8">
        <h1 className="text-h1">{title}</h1>
        {subtitle && (
          <p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
        )}
      </header>

      {steps.length === 0 ? (
        <p className="text-body-sm text-secondary">工程が未設定</p>
      ) : (
        <>
          {/* desktop: 所要日数を横軸にした一本の連続lane */}
          <div
            data-process-lane="desktop"
            className="relative hidden pt-16 pb-16 sm:block"
          >
            <div className="mb-3 flex items-center justify-between font-mono text-label text-secondary">
              <span>START 0日</span>
              <span>TOTAL {formatDays(totalDays)}日</span>
            </div>

            <div className="relative flex h-20 shadow-card">
              {steps.map((step, index) => {
                const segment = geometry[index];
                const isBottleneck = index === bottleneck;
                const labelAbove = index % 2 === 0;
                const edgeLabel =
                  index === 0
                    ? "left-0 text-left"
                    : index === steps.length - 1
                      ? "right-0 text-right"
                      : "left-1/2 -translate-x-1/2 text-center";
                const round =
                  index === 0
                    ? "rounded-l-lg border-l"
                    : index === steps.length - 1
                      ? "rounded-r-lg"
                      : "";

                return (
                  <div
                    key={index}
                    data-process-segment="true"
                    data-process-hotspot={index}
                    data-step-index={index}
                    data-priority={isBottleneck ? "true" : undefined}
                    tabIndex={0}
                    role="group"
                    aria-label={`STEP ${index + 1} ${step.label}、${formatDays(step.weight)}日、累計${formatDays(segment.cumulativeDays)}日${isBottleneck ? "、最も所要日数が長い工程" : ""}`}
                    className={`relative min-w-0 animate-rise cursor-default border-y border-r border-outline ${
                      isBottleneck
                        ? `${HIGHLIGHT_GRADIENT} text-on-tertiary`
                        : "bg-neutral text-on-neutral"
                    } ${round}`}
                    style={{
                      flexGrow: segment.widthPct,
                      flexBasis: 0,
                      animationDelay: stagger(index),
                    }}
                  >
                    <div className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden px-1">
                      <span className="font-mono text-label opacity-70">
                        {String(index + 1).padStart(2, "0")}
                      </span>
                    </div>

                    <span
                      data-process-connector="true"
                      data-step-index={index}
                      aria-hidden="true"
                      className={`absolute left-1/2 h-8 border-l border-dashed border-outline opacity-70 ${
                        labelAbove ? "bottom-full" : "top-full"
                      }`}
                    />
                    <div
                      data-process-record="true"
                      data-step-index={index}
                      className={`absolute w-36 text-on-surface ${edgeLabel} ${
                        labelAbove ? "bottom-full mb-9" : "top-full mt-9"
                      }`}
                    >
                      <p className="font-mono text-label text-secondary">
                        STEP {String(index + 1).padStart(2, "0")}
                      </p>
                      <p className="mt-1 text-body-sm">{step.label}</p>
                      <p className="mt-1 font-mono text-label">
                        {formatDays(step.weight)}日
                        {isBottleneck && (
                          <span className="ml-2 text-tertiary">最大</span>
                        )}
                      </p>
                    </div>

                    {index < steps.length - 1 && (
                      <span
                        data-process-boundary="true"
                        data-step-index={index}
                        data-cumulative-days={segment.cumulativeDays}
                        aria-label={`累計${formatDays(segment.cumulativeDays)}日`}
                        className="absolute inset-y-0 right-0 border-r border-surface/80 opacity-80"
                      >
                        <span className="absolute right-2 bottom-1 whitespace-nowrap font-mono text-label opacity-75">
                          {formatDays(segment.cumulativeDays)}日
                        </span>
                      </span>
                    )}
                  </div>
                );
              })}
            </div>
            <p className="mt-3 text-right text-label text-secondary">
              区間長 = 所要日数 ・ 境界値 = 開始からの累計日数
            </p>
          </div>

          {/* mobile: 同じ連続区間を縦軸へ回し、横overflowを避ける */}
          <div data-process-lane="mobile" className="relative h-128 sm:hidden">
            <div className="absolute inset-y-0 left-0 w-14 shadow-card">
              {steps.map((step, index) => {
                const segment = geometry[index];
                const isBottleneck = index === bottleneck;
                const round =
                  index === 0
                    ? "rounded-t-lg border-t"
                    : index === steps.length - 1
                      ? "rounded-b-lg"
                      : "";
                return (
                  <div
                    key={index}
                    data-process-segment="true"
                    data-process-hotspot={index}
                    data-step-index={index}
                    data-priority={isBottleneck ? "true" : undefined}
                    tabIndex={0}
                    role="group"
                    aria-label={`STEP ${index + 1} ${step.label}、${formatDays(step.weight)}日、累計${formatDays(segment.cumulativeDays)}日${isBottleneck ? "、最も所要日数が長い工程" : ""}`}
                    className={`absolute inset-x-0 animate-rise cursor-default border-x border-b border-outline ${
                      isBottleneck
                        ? `${HIGHLIGHT_GRADIENT} text-on-tertiary`
                        : "bg-neutral text-on-neutral"
                    } ${round}`}
                    style={{
                      top: `${segment.startPct}%`,
                      height: `${segment.widthPct}%`,
                      animationDelay: stagger(index),
                    }}
                  >
                    <span className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden font-mono text-label opacity-70">
                      {String(index + 1).padStart(2, "0")}
                    </span>
                    <span
                      data-process-connector="true"
                      data-step-index={index}
                      aria-hidden="true"
                      className="absolute top-1/2 left-full w-4 border-t border-dashed border-outline opacity-70"
                    />
                    <div
                      data-process-record="true"
                      data-step-index={index}
                      className="absolute top-1/2 left-full ml-5 w-64 -translate-y-1/2 text-on-surface"
                    >
                      <p className="font-mono text-label text-secondary">
                        STEP {String(index + 1).padStart(2, "0")}
                      </p>
                      <p className="text-body-sm">{step.label}</p>
                      <p className="font-mono text-label">
                        {formatDays(step.weight)}日 ・ 累計
                        {formatDays(segment.cumulativeDays)}日
                        {isBottleneck && (
                          <span className="ml-2 text-tertiary">最大</span>
                        )}
                      </p>
                    </div>

                    {index < steps.length - 1 && (
                      <span
                        data-process-boundary="true"
                        data-step-index={index}
                        data-cumulative-days={segment.cumulativeDays}
                        aria-label={`累計${formatDays(segment.cumulativeDays)}日`}
                        className="absolute inset-x-0 bottom-0 border-b border-surface/80 opacity-80"
                      />
                    )}
                  </div>
                );
              })}
            </div>
          </div>

          <p className="mt-4 text-label text-secondary">
            最も所要日数の長い工程「{steps[bottleneck].label}
            」を連続lane上で自動強調
          </p>
        </>
      )}

      {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((step, index) => (
              <article
                key={index}
                data-note-item="true"
                className="rounded-md bg-surface p-4"
              >
                <h3 className="text-label">{step.label}</h3>
                <p className="mt-1 font-mono text-label text-secondary">
                  {formatDays(step.weight)}日
                  {step.owner ? ` ・ 担当 ${step.owner}` : ""}
                </p>
                <p className="mt-2 text-body-sm">{step.note}</p>
              </article>
            ))}
          </div>
        </details>
      )}
    </section>
  );
}

schema.ts

1.0 KB

/designs/process-flow/source/schema.ts

中身を見る
import { z } from "zod";

export const processStepSchema = z
  .object({
    label: z.string(),
    weight: z
      .number()
      .positive()
      .describe(
        "所要日数などの相対量。連続process laneの区間長に使う。" +
          "正の数のみ —— 負値は区間長が 0.1 にクランプされる一方、累計と合計には" +
          "そのまま加算されるため、前へ進む lane と減っていく日数という食い違いになる",
      ),
    owner: z.string().optional(),
    note: z.string().optional().describe("図の下の補足欄で明示的に展開される詳細"),
  })
  .strict();

export const schema = z
  .object({
    title: z.string().describe("スライドタイトル。結論を書くメッセージラインを推奨"),
    subtitle: z.string().optional().describe("補足・出典など"),
    steps: z.array(processStepSchema),
  })
  .strict();

export type ProcessStep = z.infer<typeof processStepSchema>;
export type ProcessFlowProps = z.infer<typeof schema>;

meta.yaml

2.1 KB

/designs/process-flow/source/meta.yaml

中身を見る
id: process-flow
name:
  ja: "プロセスフロー(業務手順)"
  en: "Process Flow"
purpose:
  ja: "所要時間の比率で連続 lane の区間長を自動計算し、最も時間を食う工程を lane 上で強調する。どこが詰まっているかが長さで分かる"
  en: "One continuous lane divided in proportion to how long each step takes, with the slowest marked. Where the work piles up is legible as length"
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: workflow
catalogOrder: 50
jobs:
  - plan-sequence
  - trace-flow
useCases:
  - process
layoutKinds:
  - flow
tags: [process, workflow, sequence, data-driven, interactive, semantic-geometry, linked-emphasis]
whenToUse:
  - ja: "オンボーディング・導入手順など業務フローの可視化"
    en: "Making an onboarding or rollout procedure visible"
  - ja: "承認・発注ワークフローの整理"
    en: "Setting out an approval or ordering workflow"
  - ja: "どの工程がボトルネックかを一目で示したい場合"
    en: "Showing at a glance which step is the bottleneck"
avoidWhen:
  - ja: "分岐と合流を含む多対多の流量を示したい場合"
    en: "Showing many-to-many volume with branching and merging"
  - ja: "並行タスクと依存関係から完了日への影響を説明したい場合"
    en: "Explaining how parallel tasks and dependencies move a finish date"
requiredInputs:
  - ja: "工程名"
    en: "What each step is"
  - ja: "各工程の所要時間"
    en: "How long each takes"
  - ja: "担当者または補足"
    en: "Who owns it, or a note"
relatedLayouts: [timeline, critical-path-gantt, sankey-flow]
dataSchema:
  title: "string — スライドタイトル。結論を書くメッセージラインを推奨"
  subtitle: "string(任意)— 補足・出典など"
  steps: "{ label, weight, owner?, note? } の配列。weight(所要日数等)の比率で連続 lane の区間長を自動計算し、最大の工程を lane 上で強調表示。owner/note は図の下の補足欄で展開"
sources: []

sample-data.json

1.1 KB

/designs/process-flow/source/sample-data.json

中身を見る
{
  "title": "新規顧客オンボーディングフロー",
  "subtitle": "連続laneの区間長は所要日数に比例。最も時間のかかる工程を自動で強調(2026年度 CS部門・サンプル)",
  "steps": [
    {
      "label": "契約締結",
      "weight": 2,
      "owner": "営業",
      "note": "電子契約で完結。締結後すぐにCS部門へ引き継ぐ"
    },
    {
      "label": "初期設定",
      "weight": 3,
      "owner": "CS",
      "note": "アカウント発行・権限設定・SSO連携の初期設定を実施"
    },
    {
      "label": "データ移行",
      "weight": 7,
      "owner": "CS / 情報システム",
      "note": "既存システムからのデータ移行が最も時間を要する。移行元の形式次第で追加工数が発生しやすい"
    },
    {
      "label": "利用トレーニング",
      "weight": 4,
      "owner": "CS",
      "note": "管理者向け・利用者向けの2回に分けて実施"
    },
    {
      "label": "本稼働開始",
      "weight": 1,
      "owner": "CS",
      "note": "初月は週次で利用状況をフォローアップ"
    }
  ]
}