索引に戻る

スイムレーン・プロセス(部門横断hand-off)

フロー
生成可能
スイムレーン・プロセス(部門横断hand-off)

作品として見る

持ち帰る

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

全部入り

swimlane-process-everything.md

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

依頼文

1.5 KB
「スイムレーン・プロセス(部門横断hand-off)」でHTML資料を作ってください。この型は実行できるので、版面を組み直す必要はありません — 必要なのは中身です。

## この型がすること
担当レーンと実時間比例の工程を結び、部門間hand-offの待機時間から最大滞留を導出する

## 渡す必要があるもの
- 担当レーン
- 工程の開始時点と所要時間
- 工程順と任意の補足

## 入力の形
- `title` — string — 最大滞留を結論として書く
- `lanes` — { id, name }[] — 2〜5担当
- `tasks` — { id, label, laneId, start, duration, note? }[] — 3〜12工程

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

## 向かない場合
- 担当を分けず一本の直列手順だけを示したい場合
- 活動と役割の責任記号だけを監査したい場合

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

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

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

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

ファイル

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

template.tsx

13.7 KB

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

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

type Lane = SwimlaneProcessProps["lanes"][number];

const round = (value: number) => Number(value.toFixed(2));

// 2 つの識別子を `-` で繋ぐと単射にならない —— ("a-b","c") と ("a","b-c") が
// 同じ鍵になり、React の key と data 属性の identity が別物どうしで一致する。
// 区切りに使う文字を各辺で逃がしてから繋ぐ。
const joinIds = (...parts: (string | number)[]) =>
  parts.map((p) => String(p).replace(/[_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`)).join("-");


/**
 * 図の中に置くラベルの縁取り。`stroke-surface` と組で使う。
 *
 * 塗りの前に地の色で縁を引くので、線はラベルの手前で途切れて見え、線の上にラベルが来ても
 * 字が残る。背後に何も無いところでは地と同じ色なので見えない。線を避けて置き場を解き直す
 * 道は取らない —— 密な図では解が無い入力が必ず残るが、縁取りは入力に依らず効く。
 */
const HALO = { paintOrder: "stroke", strokeWidth: 4, strokeLinejoin: "round" } as const;

export function taskGeometry(tasks: SwimlaneTask[]) {
  const maxEnd = Math.max(
    ...tasks.map((task) => task.start + task.duration),
    1,
  );
  return tasks.map((task) => ({
    id: task.id,
    startPercent: round((task.start / maxEnd) * 100),
    widthPercent: round((task.duration / maxEnd) * 100),
  }));
}

export function rankHandoffs(tasks: SwimlaneTask[]) {
  return tasks
    .slice(0, -1)
    .map((task, index) => {
      const next = tasks[index + 1];
      return {
        fromTaskId: task.id,
        toTaskId: next.id,
        wait: Math.max(0, next.start - (task.start + task.duration)),
        crossLane: task.laneId !== next.laneId,
        inputIndex: index,
      };
    })
    .sort((a, b) => b.wait - a.wait || a.inputIndex - b.inputIndex);
}

export function swimlaneGeometry(lanes: Lane[], tasks: SwimlaneTask[]) {
  const maxEnd = Math.max(...tasks.map((task) => task.start + task.duration), 1);
  const taskNodes = tasks.map((task) => {
    const laneIndex = lanes.findIndex((lane) => lane.id === task.laneId);
    const x = 120 + (task.start / maxEnd) * 830;
    const endX = 120 + ((task.start + task.duration) / maxEnd) * 830;
    return {
      ...task,
      x: round(x),
      endX: round(endX),
      width: round(endX - x),
      y: round(62 + ((laneIndex + 0.5) / lanes.length) * 270),
      strokeWidth: round(12 + Math.min(task.duration, 8) * 2),
    };
  });
  const handoffs = taskNodes.slice(0, -1).map((task, index) => {
    const next = taskNodes[index + 1];
    const wait = Math.max(0, next.start - (task.start + task.duration));
    return {
      id: joinIds(task.id, next.id),
      source: task,
      target: next,
      wait,
      crossLane: task.laneId !== next.laneId,
      strokeWidth: round(1 + wait * 0.6),
    };
  });
  return { tasks: taskNodes, handoffs, maxEnd };
}

export default function SwimlaneProcess({
  title,
  subtitle,
  timeUnit,
  lanes,
  tasks,
  instanceId,
}: SwimlaneProcessProps) {
  // 既定を固定文字列にすると、id を指定せず 2 つ置いたときに radio の name と
  // SVG marker の id を共有してしまう。他の型と同じく useId() に寄せる。
  const autoId = useId();
  const uid = (instanceId ?? autoId).replace(
      // 逃がし文字(`_`)自身も逃がすので単射。捨てる形にすると `a:b` と `a/b` が
      // 同じ id になる。
      /[^a-zA-Z0-9-]/g,
      (c: string) => `_${c.charCodeAt(0).toString(16)}_`,
    ) || "swimlane-process";
  const geometry = swimlaneGeometry(lanes, tasks);
  const handoffs = rankHandoffs(tasks);
  const priority = handoffs[0];
  const notes = tasks.filter((task) => task.note);
  const markerId = `${uid}-handoff-arrow`;
  const drawnHandoffs = geometry.handoffs.map((handoff) => ({
    handoff,
    isPriority: priority?.fromTaskId === handoff.source.id,
    bendX:
      handoff.source.endX +
      Math.max(8, (handoff.target.x - handoff.source.endX) / 2),
  }));

  return (
    <section
      className="asset group/swimlane overflow-hidden rounded-xl border border-outline bg-surface p-5 text-primary shadow-card sm:p-8"
      data-layout="swimlane-process"
    >
      <header className="border-b border-outline pb-5">
        <p className="font-mono text-label text-tertiary">CROSS-FUNCTIONAL FLOW</p>
        <h1 className="mt-2 text-h1 text-balance">{title}</h1>
        {subtitle && <p className="mt-2 text-body text-secondary">{subtitle}</p>}
      </header>

      <div className="mt-5 flex flex-wrap items-center justify-between gap-3">
        <div className="inline-flex rounded-full border border-outline bg-surface-subtle p-1 text-label">
          <label className="cursor-pointer rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="swimlane-view-process sr-only" type="radio" name={`${uid}-view`} defaultChecked />工程
          </label>
          <label className="cursor-pointer rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="swimlane-view-ledger sr-only" type="radio" name={`${uid}-view`} />滞留診断
          </label>
        </div>
        <p className="font-mono text-label text-secondary">LENGTH=DURATION · GAP=WAIT · Y=OWNER</p>
      </div>

      {priority && (
        <div
          className="mt-5 grid gap-2 border-l-4 border-tertiary bg-gradient-to-r from-tertiary/20 to-transparent px-4 py-3 sm:grid-cols-[auto_1fr_auto] sm:items-center"
          data-swimlane-default-priority="true"
        >
          <span className="font-mono text-label text-tertiary">LONGEST WAIT</span>
          <strong>{tasks.find((task) => task.id === priority.fromTaskId)?.label} → {tasks.find((task) => task.id === priority.toTaskId)?.label}</strong>
          <span className="font-mono text-h3">{priority.wait}{timeUnit}</span>
        </div>
      )}

      <div data-swimlane-view="process" className="mt-6 group-has-[.swimlane-view-ledger:checked]/swimlane:hidden">
        <svg
          className="hidden h-80 w-full sm:block"
          viewBox="0 0 1000 360"
          role="img"
          aria-label="担当レーンを横断し、工程期間と待機期間を時間比例で示す業務フロー"
          data-primary-geometry="swimlane-process"
          data-geometry-channels="time-position duration-length duration-thickness owner-lane wait-gap"
        >
          <defs>
            <marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto">
              <path d="M 0 0 L 10 5 L 0 10 z" className="fill-primary" />
            </marker>
          </defs>

          {lanes.map((lane, index) => {
            const y = 62 + ((index + 0.5) / lanes.length) * 270;
            const boundaryY = 62 + (index / lanes.length) * 270;
            return (
              <g key={lane.id}>
                <line x1="112" x2="960" y1={boundaryY} y2={boundaryY} className="stroke-outline" vectorEffect="non-scaling-stroke" />
                <text x="102" y={y + 4} textAnchor="end" className="fill-secondary font-mono text-label">L{index + 1} · {lane.name}</text>
              </g>
            );
          })}
          <line x1="112" x2="960" y1="332" y2="332" className="stroke-outline" vectorEffect="non-scaling-stroke" />
          <text x="120" y="24" className="fill-secondary font-mono text-label">0{timeUnit}</text>
          <text x="950" y="24" textAnchor="end" className="fill-secondary font-mono text-label">{geometry.maxEnd}{timeUnit}</text>

          {drawnHandoffs.map(({ handoff, isPriority, bendX }) => (
            <path
              key={handoff.id}
              d={`M ${handoff.source.endX + 5} ${handoff.source.y} H ${bendX} V ${handoff.target.y} H ${handoff.target.x - 10}`}
              fill="none"
              className={isPriority ? "stroke-tertiary" : "stroke-primary"}
              strokeWidth={handoff.strokeWidth}
              strokeDasharray={handoff.wait > 0 ? "7 6" : undefined}
              vectorEffect="non-scaling-stroke"
              markerEnd={`url(#${markerId})`}
              data-primary-edge={handoff.id}
            />
          ))}

          {geometry.tasks.map((task, index) => {
            const isPriority = priority?.toTaskId === task.id;
            const centerX = (task.x + task.endX) / 2;
            return (
              <g
                key={task.id}
                tabIndex={0}
                data-swimlane-task={task.id}
                data-primary-record={task.id}
                data-geometry-a={task.start}
                data-geometry-b={task.duration}
                data-primary-emphasis={isPriority ? "true" : undefined}
              >
                {isPriority && <line x1={task.x - 7} x2={task.endX + 7} y1={task.y} y2={task.y} className="stroke-tertiary/20" strokeWidth={task.strokeWidth + 16} strokeLinecap="round" />}
                <line x1={task.x} x2={task.endX} y1={task.y} y2={task.y} className={isPriority ? "stroke-tertiary" : "stroke-primary"} strokeWidth={task.strokeWidth} strokeLinecap="round" />
                <circle cx={task.x} cy={task.y} r="5" className="fill-surface" />
                <circle cx={task.endX} cy={task.y} r="5" className="fill-surface" />
                <text data-primary-label={task.id} x={centerX} y={task.y - task.strokeWidth / 2 - 36} textAnchor="middle" className="fill-primary stroke-surface text-label font-semibold" {...HALO}>T{String(index + 1).padStart(2, "0")}</text>
                <text x={centerX} y={task.y + task.strokeWidth / 2 + 18} textAnchor="middle" className="fill-secondary stroke-surface font-mono text-label" {...HALO}>{task.start}–{task.start + task.duration}</text>
              </g>
            );
          })}

          {/*
            待ちのラベルは線より後に置く。SVG は文書の順に描くので、縁取りは自分より前に
            描かれたものしか止められない —— 引き渡しの線の中に混ぜると、後の引き渡しや
            工程の線がラベルの上に来る(system-landscape-map で同じ並びが実測の事故になった)。
          */}
          {drawnHandoffs.map(({ handoff, isPriority, bendX }) =>
            handoff.wait > 0 ? (
              <text key={handoff.id} x={bendX} y={(handoff.source.y + handoff.target.y) / 2 - 7} textAnchor="middle" className={isPriority ? "fill-tertiary stroke-surface font-mono text-label" : "fill-secondary stroke-surface font-mono text-label"} {...HALO}>WAIT {handoff.wait}{timeUnit}</text>
            ) : null,
          )}
        </svg>

        <div className="relative space-y-3 pl-7 sm:hidden">
          <span className="absolute bottom-2 left-2 top-2 w-px bg-outline" aria-hidden="true" />
          {tasks.map((task, index) => (
            <article key={task.id} data-swimlane-mobile-task={task.id} className="relative grid grid-cols-[auto_1fr_auto] gap-3 border-b border-outline py-3 pl-3">
              <span className={`absolute -left-6 top-4 h-3 w-3 rounded-full ${priority?.toTaskId === task.id ? "bg-tertiary ring-4 ring-tertiary/20" : "bg-primary"}`} aria-hidden="true" />
              <span className="font-mono text-label text-tertiary">{String(index + 1).padStart(2, "0")}</span>
              <div><strong>{task.label}</strong><p className="text-label text-secondary">{lanes.find((lane) => lane.id === task.laneId)?.name}</p></div>
              <span className="font-mono text-label">{task.start}→{task.start + task.duration}{timeUnit}</span>
            </article>
          ))}
        </div>
      </div>

      <div className="swimlane-process-key mt-4 hidden gap-x-6 gap-y-2 border-t border-outline pt-4 sm:grid sm:grid-cols-2 xl:grid-cols-3" aria-label="工程一覧">
        {tasks.map((task, index) => <div key={task.id} data-swimlane-key={task.id} data-swimlane-task={task.id} className="grid grid-cols-[3rem_1fr] gap-2 py-1">
          <strong className="font-mono text-label text-tertiary">T{String(index + 1).padStart(2, "0")}</strong>
          <span>{task.label}<span className="block text-label text-secondary">{lanes.find((lane) => lane.id === task.laneId)?.name} · {task.start}→{task.start + task.duration}{timeUnit}</span></span>
        </div>)}
      </div>

      <div data-swimlane-view="handoff-ledger" className="mt-6 hidden group-has-[.swimlane-view-ledger:checked]/swimlane:block">
        <div className="divide-y divide-outline border-y border-outline">
          {handoffs.map((handoff, index) => (
            <article key={handoff.fromTaskId} data-swimlane-task={handoff.toTaskId} className={`grid grid-cols-[3rem_1fr_auto] items-center gap-3 py-3 ${index === 0 ? "border-l-4 border-tertiary pl-3" : "pl-4"}`}>
              <span className="font-mono text-label text-secondary">{String(index + 1).padStart(2, "0")}</span>
              <div><strong>{tasks.find((task) => task.id === handoff.fromTaskId)?.label} → {tasks.find((task) => task.id === handoff.toTaskId)?.label}</strong><p className="text-label text-secondary">{handoff.crossLane ? "部門間hand-off" : "同一lane"}</p></div>
              <span className="font-mono text-h3">{handoff.wait}{timeUnit}</span>
            </article>
          ))}
        </div>
      </div>

      {notes.length > 0 && (
        <details data-inline-disclosure className="mt-6 rounded-lg border border-outline bg-surface-subtle p-4">
          <summary className="cursor-pointer font-semibold">補足を確認({notes.length}件)</summary>
          <div className="mt-3 space-y-2">{notes.map((task) => <p key={task.id} data-note-item="true" className="text-body text-secondary"><strong className="text-primary">{task.label}:</strong> {task.note}</p>)}</div>
        </details>
      )}
    </section>
  );
}

schema.ts

1.8 KB

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

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

export const laneSchema = z.object({ id: z.string().min(1), name: z.string().min(1) }).strict();
export const swimlaneTaskSchema = z.object({
  id: z.string().min(1), label: z.string().min(1), laneId: z.string().min(1),
  start: z.number().nonnegative(), duration: z.number().positive(), note: z.string().optional(),
}).strict();
export const schema = z.object({
  title: z.string().min(1), subtitle: z.string().optional(), timeUnit: z.string().min(1),
  lanes: z.array(laneSchema).min(2).max(5), tasks: z.array(swimlaneTaskSchema).min(3).max(12),
  instanceId: z.string().optional(),
}).strict().superRefine((value, ctx) => {
  // id は identity として使う(React の key、data 属性、対応強調の突き合わせ)。
  // 重複すると別物どうしが同じ要素を指し、操作が意図しない相手に効く。
  {
    const ids = value.lanes.map((item) => item.id);
    if (new Set(ids).size !== ids.length) {
      ctx.addIssue({ code: "custom", path: ["lanes"], message: "lanes ids must be unique" });
    }
  }
  {
    const ids = value.tasks.map((item) => item.id);
    if (new Set(ids).size !== ids.length) {
      ctx.addIssue({ code: "custom", path: ["tasks"], message: "tasks ids must be unique" });
    }
  }
  const lanes = new Set(value.lanes.map((lane) => lane.id));
  const taskIds = new Set<string>();
  value.tasks.forEach((task, index) => {
    if (!lanes.has(task.laneId)) ctx.addIssue({ code: "custom", path: ["tasks", index, "laneId"], message: "laneId must reference lanes" });
    if (taskIds.has(task.id)) ctx.addIssue({ code: "custom", path: ["tasks", index, "id"], message: "task id must be unique" });
    taskIds.add(task.id);
  });
});
export type SwimlaneTask = z.infer<typeof swimlaneTaskSchema>;
export type SwimlaneProcessProps = z.infer<typeof schema>;

meta.yaml

1.9 KB

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

中身を見る
id: swimlane-process
name:
  ja: "スイムレーン・プロセス(部門横断hand-off)"
  en: "Swimlane Process (Cross-team Hand-off)"
purpose:
  ja: "担当レーンと実時間比例の工程を結び、部門間hand-offの待機時間から最大滞留を導出する"
  en: "Owner lanes joined to steps in proportion to real time, with the longest wait between teams read off the hand-offs"
# A5 の裁定で draft として移設し、2026-09-04 に型ごとの承認を取って公開した。
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: workflow
catalogOrder: 150
jobs:
  - trace-flow
  - plan-sequence
  - prioritize-diagnose
useCases:
  - process
layoutKinds:
  - flow
tags: [swimlane, workflow, handoff, lead-time, diagnostic]
whenToUse:
  - ja: "部門横断の引き継ぎと待機を同じ時系列で説明したい場合"
    en: "Explaining hand-offs and waiting across teams on one timeline"
  - ja: "業務改革で滞留点を合意したい場合"
    en: "Agreeing where work stalls, during a process change"
avoidWhen:
  - ja: "担当を分けず一本の直列手順だけを示したい場合"
    en: "A single serial procedure with no owners to separate"
  - ja: "活動と役割の責任記号だけを監査したい場合"
    en: "Auditing only the responsibility letters against activities"
requiredInputs:
  - ja: "担当レーン"
    en: "The owner lanes"
  - ja: "工程の開始時点と所要時間"
    en: "When each step starts and how long it takes"
  - ja: "工程順と任意の補足"
    en: "The order of steps, and any notes"
relatedLayouts: [process-flow, raci-matrix, critical-path-gantt]
dataSchema:
  title: "string — 最大滞留を結論として書く"
  lanes: "{ id, name }[] — 2〜5担当"
  tasks: "{ id, label, laneId, start, duration, note? }[] — 3〜12工程"
sources: []

sample-data.json

863 B

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

中身を見る
{
  "title": "申込から利用開始まで、審査待ち3日を最初に解消する",
  "subtitle": "法人向けサービス標準フロー(所要・開始は営業日)",
  "timeUnit": "日",
  "lanes": [{"id":"sales","name":"営業"},{"id":"review","name":"審査"},{"id":"ops","name":"導入支援"}],
  "tasks": [
    {"id":"intake","label":"申込受付","laneId":"sales","start":0,"duration":1},
    {"id":"check","label":"契約審査","laneId":"review","start":4,"duration":2,"note":"申込情報の確認待ちが平均3営業日発生する想定"},
    {"id":"design","label":"導入設計","laneId":"ops","start":7,"duration":3},
    {"id":"approve","label":"顧客確認","laneId":"sales","start":11,"duration":2},
    {"id":"launch","label":"利用開始","laneId":"ops","start":13,"duration":1}
  ],
  "instanceId": "onboarding-swimlane"
}