Effort Flow Diagram — Sankey Allocation
A Sankey-style diagram showing how total effort splits from disciplines into phases and merges into deliverables, with ribbon thickness carrying the quantity. Hovering or focusing a label leaves only the ribbons connected to that node. Series identity is encoded with fill patterns rather than colour so that opacity stays free as the highlight channel, and the SVG draws only ribbons and nodes while labels sit above it as absolutely positioned HTML buttons, keeping type size constant at any scale. Narrow screens re-form the same data as stacked proportion bars. No images, no dependencies.
- Added:
- 2026-09-20
- Dependencies:
- None
- tags
- #diagram #sankey #data #svg #allocation #estimate #process #section #brand #no-image
Preview
いただいた費用が、どの工程を通って
何として残るのか
標準的なコーポレートサイト1件の工数を、職能・工程・納品物の3段で分解した図です。帯の太さがそのまま時間の量で、細い帯は「そこにはあまり時間を使っていない」ことを表します。名前にふれると、そこにつながる帯だけが残ります。
- 総工数
- 480h
- 実装の比率
- 38%
- ディレクション96h
- デザイン168h
- フロントエンド216h
要件・設計
88h/18%
構成と優先順位を決める
- 内訳
- ディレクション 48h / デザイン 24h / フロントエンド 16h
- 残るもの
- 設計ドキュメント 64h / デザインデータ 12h / 公開サイト 12h
画面デザイン
136h/28%
書体・余白・階層を固める
- 内訳
- ディレクション 16h / デザイン 108h / フロントエンド 12h
- 残るもの
- 設計ドキュメント 12h / デザインデータ 104h / 公開サイト 20h
実装
184h/38%
コード化と表示調整
- 内訳
- ディレクション 12h / デザイン 24h / フロントエンド 148h
- 残るもの
- 設計ドキュメント 8h / 公開サイト 176h
検証・公開
72h/15%
計測設定と引き継ぎ
- 内訳
- ディレクション 20h / デザイン 12h / フロントエンド 40h
- 残るもの
- 設計ドキュメント 12h / 公開サイト 60h
ページ数・原稿のご準備状況・既存データの有無で配分は変わります。見積書にも同じ3段で内訳を記載します。
内訳の考え方を詳しく見る"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600"],
subsets: ["latin"],
display: "swap",
});
/**
* 工数の流れ図(サンキー型・配分ダイアグラム)
* 既存の工程系ブロックは「順番」「読み進みの進捗」「期間(時間軸)」を見せる1工程=1行のリストで、
* どれも量そのものは文字で添えるだけだった。本ブロックは行を捨て、総工数が職能→工程→納品物へ
* 分岐して合流する様子を帯の太さ=量として見せる図解にした。主役は行ではなく帯のつながりで、
* 節点にふれると、そこにつながる帯だけが残る(工程を選べば入ってくる内訳と出ていく先が同時に見える)。
* 飽和idiomは英字キッカーと末尾の「→」リンクを外し、角丸もゼロ(直線と帯だけの作図に寄せる)。
* 掲載している数値・職能名はすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **サンキーの帯は3次ベジェ1本で閉じる**: 始端(x0,y0t..y0b)と終端(x1,y1t..y1b)を
* `M x0,y0t C xm,y0t xm,y1t x1,y1t L x1,y1b C xm,y1b xm,y0b x0,y0b Z`(xm=中点)で描く。
* 上辺と下辺が同じ制御点x を共有するので、太さが途中で不自然にくびれない。
* - **スケールは列をまたいで1つ**: 列ごとに高さを割り当てると同じ数値の帯が列で太さを変えてしまう。
* `min(列ごとの (使える高さ - 節点間の隙間) / 総量)` を全列共通のスケールにし、各列は縦中央に寄せる。
* - **文字と当たり判定はHTMLで上に重ねる**: SVGの<text>はviewBoxと一緒に縮むので、幅672pxの
* コンテナでは8pxまで落ちて読めなくなる。帯と節点だけをSVGに描き、ラベルは絶対配置のHTML
* <button>(left/topは%で、文字サイズはpxで固定)にすると、縮尺が変わっても文字は一定で、
* focus-visible・aria・hoverがブラウザ標準のまま手に入る。
* - **識別は色でなく塗りのパターンで持つ**: 単色ブランドだと系列の色分けができない。45°斜線・
* 横罫のSVG <pattern> を identity に使い、**opacityは状態(強調/減衰)専用の1チャンネル**として
* 空けておくと、ホバー表現が transition:opacity だけで書ける。
* - **狭幅は縮小でなく別表現に置き換える**: サンキーは375pxでは原理的に読めない。図はコンテナ
* クエリで隠し、同じ配列から積み上げ棒のリストを組む。広幅では図をaria-hiddenにしてリスト側を
* `@2xl:sr-only` で残すので、DOMは常に読める(支援技術から情報が消えない)。
*/
type Flow = { from: number; to: number; value: number };
/** 左列: 職能(工数の出どころ) */
const resources = [
{ name: "ディレクション", note: "要件整理・進行", value: 96 },
{ name: "デザイン", note: "画面設計・作字", value: 168 },
{ name: "フロントエンド", note: "実装・計測", value: 216 },
];
/** 中列: 工程 */
const phases = [
{ name: "要件・設計", note: "構成と優先順位を決める", value: 88 },
{ name: "画面デザイン", note: "書体・余白・階層を固める", value: 136 },
{ name: "実装", note: "コード化と表示調整", value: 184 },
{ name: "検証・公開", note: "計測設定と引き継ぎ", value: 72 },
];
/** 右列: 納品物 */
const deliverables = [
{ name: "設計ドキュメント", note: "構成図・仕様メモ", value: 96 },
{ name: "デザインデータ", note: "全画面・部品一式", value: 116 },
{ name: "公開サイト", note: "本番環境・計測", value: 268 },
];
/** 職能→工程(各行の合計=職能の工数) */
const flowsA: Flow[] = [
{ from: 0, to: 0, value: 48 },
{ from: 0, to: 1, value: 16 },
{ from: 0, to: 2, value: 12 },
{ from: 0, to: 3, value: 20 },
{ from: 1, to: 0, value: 24 },
{ from: 1, to: 1, value: 108 },
{ from: 1, to: 2, value: 24 },
{ from: 1, to: 3, value: 12 },
{ from: 2, to: 0, value: 16 },
{ from: 2, to: 1, value: 12 },
{ from: 2, to: 2, value: 148 },
{ from: 2, to: 3, value: 40 },
];
/** 工程→納品物(各行の合計=工程の工数) */
const flowsB: Flow[] = [
{ from: 0, to: 0, value: 64 },
{ from: 0, to: 1, value: 12 },
{ from: 0, to: 2, value: 12 },
{ from: 1, to: 0, value: 12 },
{ from: 1, to: 1, value: 104 },
{ from: 1, to: 2, value: 20 },
{ from: 2, to: 0, value: 8 },
{ from: 2, to: 2, value: 176 },
{ from: 3, to: 0, value: 12 },
{ from: 3, to: 2, value: 60 },
];
const TOTAL = resources.reduce((sum, r) => sum + r.value, 0);
/* ── 作図のパラメータ(すべて viewBox のユーザー単位) ───────────────── */
const VB_W = 780;
const VB_H = 460;
const NODE_W = 8;
const COL_X = [118, 386, 654];
const PAD_TOP = 30;
const PAD_BOTTOM = 14;
const GAP = [40, 34, 40];
type Node = {
id: string;
col: number;
index: number;
name: string;
note: string;
value: number;
y0: number;
y1: number;
};
type Ribbon = {
id: string;
stage: number;
source: string;
target: string;
value: number;
d: string;
};
/**
* 節点と帯の座標を1回だけ組む。純粋関数なのでモジュール直下で評価でき、
* SSRとクライアントで必ず同じ値になる(state を持たない=ハイドレーション差分が出ない)。
*/
function buildDiagram() {
const usable = VB_H - PAD_TOP - PAD_BOTTOM;
const counts = [resources.length, phases.length, deliverables.length];
// 列をまたいで共通のスケール。これを列ごとに変えると同じ数値の帯が列で太さを変えてしまう
const scale = Math.min(
...counts.map((n, col) => (usable - (n - 1) * GAP[col]) / TOTAL)
);
const columns = [resources, phases, deliverables];
const nodes: Node[] = [];
columns.forEach((items, col) => {
const height = TOTAL * scale + (items.length - 1) * GAP[col];
let y = PAD_TOP + (usable - height) / 2;
items.forEach((item, index) => {
const h = item.value * scale;
nodes.push({
id: `n${col}-${index}`,
col,
index,
name: item.name,
note: item.note,
value: item.value,
y0: y,
y1: y + h,
});
y += h + GAP[col];
});
});
const nodeOf = (col: number, index: number) =>
nodes.find((n) => n.col === col && n.index === index)!;
const ribbons: Ribbon[] = [];
[flowsA, flowsB].forEach((flows, stage) => {
// 節点の内側で帯を積む順は相手側の並び順にする(交差が最小になる)
const outCursor = new Map<string, number>();
const inCursor = new Map<string, number>();
const sorted = [...flows].sort((a, b) => a.from - b.from || a.to - b.to);
const byTarget = [...flows].sort((a, b) => a.to - b.to || a.from - b.from);
const targetOffset = new Map<string, number>();
byTarget.forEach((f) => {
const target = nodeOf(stage + 1, f.to);
const base = inCursor.get(target.id) ?? target.y0;
targetOffset.set(`${stage}-${f.from}-${f.to}`, base);
inCursor.set(target.id, base + f.value * scale);
});
sorted.forEach((f) => {
const source = nodeOf(stage, f.from);
const target = nodeOf(stage + 1, f.to);
const sy0 = outCursor.get(source.id) ?? source.y0;
const sy1 = sy0 + f.value * scale;
outCursor.set(source.id, sy1);
const ty0 = targetOffset.get(`${stage}-${f.from}-${f.to}`)!;
const ty1 = ty0 + f.value * scale;
const x0 = COL_X[stage] + NODE_W;
const x1 = COL_X[stage + 1];
const xm = (x0 + x1) / 2;
ribbons.push({
id: `f${stage}-${f.from}-${f.to}`,
stage,
source: source.id,
target: target.id,
value: f.value,
// 上辺と下辺で制御点xを共有すると、途中でくびれない帯になる
d: `M${x0},${sy0} C${xm},${sy0} ${xm},${ty0} ${x1},${ty0} L${x1},${ty1} C${xm},${ty1} ${xm},${sy1} ${x0},${sy1} Z`,
});
});
});
return { nodes, ribbons };
}
const { nodes, ribbons } = buildDiagram();
/** 狭幅の積み上げ棒用: 工程ごとの職能内訳 */
const phaseBreakdown = phases.map((phase, pi) => ({
...phase,
parts: resources.map((resource, ri) => ({
name: resource.name,
ri,
value: flowsA.find((f) => f.from === ri && f.to === pi)?.value ?? 0,
})),
sends: flowsB
.filter((f) => f.from === pi)
.map((f) => `${deliverables[f.to].name} ${f.value}h`),
}));
const maxPhase = Math.max(...phases.map((p) => p.value));
/** 職能の識別は色でなく塗りのパターンで持つ(opacityは状態専用に空けておく) */
const patternId = ["blk71-fill-0", "blk71-fill-1", "blk71-fill-2"];
const swatch = [
"bg-amber-600",
"bg-amber-600/45",
"border border-amber-600/70 bg-white",
];
const swatchStripe = [
undefined,
undefined,
"repeating-linear-gradient(90deg, rgba(217,119,6,0.75) 0 1px, transparent 1px 5px)",
];
const pct = (n: number, total: number) => `${((n / total) * 100).toFixed(0)}%`;
export default function EffortFlowDiagram() {
const rootRef = useRef<HTMLElement>(null);
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
// 動きを控える設定では「伸び切った状態」を唯一の状態にする
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
root.setAttribute("data-shown", "");
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
root.setAttribute("data-shown", "");
observer.unobserve(entry.target);
}
},
{ rootMargin: "0px 0px -15% 0px" }
);
observer.observe(root);
return () => observer.disconnect();
}, []);
const linked = (id: string) =>
active === id ||
ribbons.some(
(r) =>
(r.source === active && r.target === id) ||
(r.target === active && r.source === id)
);
const ribbonState = (r: Ribbon) => {
if (!active) return "idle";
return r.source === active || r.target === active ? "on" : "off";
};
const nodeState = (id: string) => {
if (!active) return "idle";
return linked(id) ? "on" : "off";
};
const clear = () => setActive(null);
return (
<section
ref={rootRef}
aria-labelledby="blk71-title"
className="@container group w-full max-w-5xl bg-white px-5 py-10 text-gray-900 sm:px-8 sm:py-14"
>
<style>{`
.blk71-ribbons{opacity:0;transform:scaleX(0);transform-box:view-box;transform-origin:${COL_X[0]}px 50%;transition:transform 900ms cubic-bezier(.22,.61,.36,1),opacity 500ms ease-out}
[data-shown] .blk71-ribbons{opacity:1;transform:none}
.blk71-ribbon{transition:opacity 260ms ease-out}
.blk71-ribbon[data-state="idle"]{opacity:.42}
.blk71-ribbon[data-state="on"]{opacity:.9}
.blk71-ribbon[data-state="off"]{opacity:.08}
.blk71-ribbon[data-stage="1"][data-state="idle"]{opacity:.26}
.blk71-node{transition:opacity 260ms ease-out}
.blk71-node[data-state="off"]{opacity:.25}
@media (prefers-reduced-motion: reduce){
.blk71-ribbons{opacity:1;transform:none;transition:none}
.blk71-ribbon,.blk71-node{transition:none}
}
`}</style>
<header className="@2xl:flex @2xl:items-end @2xl:justify-between @2xl:gap-10">
<div>
<h2
id="blk71-title"
className="text-[clamp(1.35rem,3.4vw,1.9rem)] leading-[1.5] font-bold tracking-tight"
>
いただいた費用が、どの工程を通って
<br />
何として残るのか
</h2>
<p className="mt-5 max-w-md text-[13px] leading-[2] text-gray-600">
標準的なコーポレートサイト1件の工数を、職能・工程・納品物の3段で分解した図です。帯の太さがそのまま時間の量で、細い帯は「そこにはあまり時間を使っていない」ことを表します。名前にふれると、そこにつながる帯だけが残ります。
</p>
</div>
<dl
className={`${montserrat.className} mt-8 flex gap-8 @2xl:mt-0 @2xl:shrink-0`}
>
<div>
<dt className="text-[10px] tracking-wide text-gray-500">総工数</dt>
<dd className="mt-1 text-[1.75rem] leading-none font-semibold tabular-nums">
{TOTAL}
<span className="ml-1 text-[13px] font-medium text-gray-500">h</span>
</dd>
</div>
<div>
<dt className="text-[10px] tracking-wide text-gray-500">実装の比率</dt>
<dd className="mt-1 text-[1.75rem] leading-none font-semibold tabular-nums">
{pct(phases[2].value, TOTAL)}
</dd>
</div>
</dl>
</header>
{/* 凡例(職能=塗りのパターン) */}
<ul className="mt-8 flex flex-wrap gap-x-6 gap-y-2 border-t border-gray-200 pt-4 text-[11px] text-gray-600">
{resources.map((resource, ri) => (
<li key={resource.name} className="flex items-center gap-2">
<span
aria-hidden="true"
className={`h-2.5 w-6 shrink-0 ${swatch[ri]}`}
style={swatchStripe[ri] ? { backgroundImage: swatchStripe[ri] } : undefined}
/>
{resource.name}
<span className={`${montserrat.className} tabular-nums text-gray-400`}>
{resource.value}h
</span>
</li>
))}
</ul>
{/* ── 広幅: サンキー図。SVGは帯と節点だけ、文字と当たり判定はHTMLで重ねる ── */}
<div
className="relative mt-10 hidden @2xl:block"
onPointerLeave={clear}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as HTMLElement | null))
clear();
}}
>
<div className="grid grid-cols-3 text-[10px] text-gray-500">
<span>職能</span>
<span className="pl-[6%]">工程</span>
<span className="text-right">納品物</span>
</div>
<div className="relative mt-3" style={{ aspectRatio: `${VB_W} / ${VB_H}` }}>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="absolute inset-0 h-full w-full"
aria-hidden="true"
focusable="false"
>
<defs>
<pattern
id={patternId[0]}
width="6"
height="6"
patternUnits="userSpaceOnUse"
>
<rect width="6" height="6" fill="#d97706" />
</pattern>
<pattern
id={patternId[1]}
width="6"
height="6"
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<rect width="6" height="6" fill="#fff" />
<rect width="3" height="6" fill="#d97706" />
</pattern>
<pattern
id={patternId[2]}
width="5"
height="5"
patternUnits="userSpaceOnUse"
>
<rect width="5" height="5" fill="#fff" />
<rect width="5" height="1" fill="#d97706" />
</pattern>
</defs>
<g className="blk71-ribbons">
{ribbons.map((ribbon) => (
<path
key={ribbon.id}
className="blk71-ribbon"
data-stage={ribbon.stage}
data-state={ribbonState(ribbon)}
d={ribbon.d}
fill={
ribbon.stage === 0
? `url(#${patternId[Number(ribbon.source.split("-")[1])]})`
: "#d97706"
}
/>
))}
</g>
{nodes.map((node) => (
<rect
key={node.id}
className="blk71-node"
data-state={nodeState(node.id)}
x={COL_X[node.col]}
y={node.y0}
width={NODE_W}
height={Math.max(2, node.y1 - node.y0)}
fill="#111827"
/>
))}
</svg>
{/* ラベル=当たり判定。文字サイズはpxのままなので縮尺が変わっても読める */}
{nodes.map((node) => {
const middle = node.col === 1;
const left = node.col === 2;
// 箱の幅は中身に任せる(focus-visible の枠がラベルにぴたりと付く)。
// 位置だけを viewBox 座標の % で与え、列ごとに基準の辺を変える。
const box = middle
? {
left: `${(COL_X[1] / VB_W) * 100}%`,
top: `${((node.y0 - 26) / VB_H) * 100}%`,
height: `${(24 / VB_H) * 100}%`,
}
: left
? {
left: `${((COL_X[2] + NODE_W + 10) / VB_W) * 100}%`,
top: `${(node.y0 / VB_H) * 100}%`,
height: `${((node.y1 - node.y0) / VB_H) * 100}%`,
}
: {
right: `${((VB_W - COL_X[0] + 10) / VB_W) * 100}%`,
top: `${(node.y0 / VB_H) * 100}%`,
height: `${((node.y1 - node.y0) / VB_H) * 100}%`,
};
return (
<button
key={node.id}
type="button"
data-state={nodeState(node.id)}
onPointerEnter={() => setActive(node.id)}
onFocus={() => setActive(node.id)}
onClick={() => setActive((prev) => (prev === node.id ? null : node.id))}
className={`absolute flex flex-col justify-center leading-tight whitespace-nowrap transition-opacity duration-300 ease-out focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 data-[state=off]:opacity-40 motion-reduce:transition-none ${
node.col === 0 ? "items-end text-right" : "items-start text-left"
} ${middle ? "justify-end" : ""}`}
style={box}
>
<span
className={`text-[11px] font-bold ${
middle ? "bg-white/90 pr-1.5 pl-0.5" : ""
}`}
>
{node.name}
<span
className={`${montserrat.className} ml-1.5 text-[10px] font-medium tabular-nums text-gray-500`}
>
{node.value}h
</span>
</span>
{!middle && (
<span className="mt-0.5 text-[10px] text-gray-400">{node.note}</span>
)}
</button>
);
})}
</div>
<p className="mt-4 text-[11px] leading-[1.9] text-gray-500">
中央の縦棒の高さが工程の工数、左右をつなぐ帯がその内訳です。もう一度ふれるか、図の外に出ると全体表示に戻ります。
</p>
</div>
{/* ── 狭幅: 同じ配列から積み上げ棒に組み直す。広幅では sr-only で情報を残す ── */}
<ol className="mt-8 @2xl:sr-only">
{phaseBreakdown.map((phase, pi) => (
<li key={phase.name} className="border-b border-gray-100 py-4 last:border-b-0">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-[13px] font-bold">{phase.name}</h3>
<p className={`${montserrat.className} text-[11px] tabular-nums text-gray-500`}>
{phase.value}h
<span className="mx-1.5 text-gray-300">/</span>
{pct(phase.value, TOTAL)}
</p>
</div>
<div
className="mt-2.5 flex h-2.5 origin-left scale-x-0 transition-transform duration-700 ease-out group-data-[shown]:scale-x-100 motion-reduce:scale-x-100 motion-reduce:transition-none"
style={{
width: `${(phase.value / maxPhase) * 100}%`,
transitionDelay: `${pi * 70}ms`,
}}
>
{phase.parts
.filter((part) => part.value > 0)
.map((part) => (
<span
key={part.name}
className={`block h-full ${swatch[part.ri]}`}
style={{
width: `${(part.value / phase.value) * 100}%`,
...(swatchStripe[part.ri]
? { backgroundImage: swatchStripe[part.ri] }
: null),
}}
/>
))}
</div>
<p className="mt-2 text-[11px] leading-[1.9] text-gray-500">{phase.note}</p>
<dl className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-gray-400">
<div className="flex gap-1.5">
<dt className="shrink-0">内訳</dt>
<dd className={`${montserrat.className} tabular-nums`}>
{phase.parts
.filter((part) => part.value > 0)
.map((part) => `${part.name} ${part.value}h`)
.join(" / ")}
</dd>
</div>
<div className="flex gap-1.5">
<dt className="shrink-0">残るもの</dt>
<dd className={`${montserrat.className} tabular-nums`}>
{phase.sends.join(" / ")}
</dd>
</div>
</dl>
</li>
))}
</ol>
<div className="mt-6 flex flex-col gap-4 border-t border-gray-200 pt-5 text-[11px] leading-[1.9] text-gray-500 @2xl:flex-row @2xl:items-center @2xl:justify-between">
<p>
ページ数・原稿のご準備状況・既存データの有無で配分は変わります。見積書にも同じ3段で内訳を記載します。
</p>
<a
href="#"
className="group/link relative shrink-0 self-start text-[13px] font-bold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 @2xl:self-auto"
>
内訳の考え方を詳しく見る
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-left bg-gray-900 transition-transform duration-300 ease-out group-hover/link:scale-x-0 motion-reduce:transition-none"
/>
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover/link:origin-left group-hover/link:scale-x-100 motion-reduce:transition-none"
/>
</a>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/effort-flow-diagram.json