受注管理→経理手作業最大流路
請求区分と費目を担当者が再入力。月約140時間
420件/日
型番 sankey-flow
複数の起点から複数の終点へ流れる量を、合計量比例の端点railと積層曲線として自動描画する。流路を辿ると対応する起点・終点・合計値が連動し、同じデータを流量図から流路ランキングへ切り替えられる
sankey · flow · allocation · routing · data-driven · interactive
LIVE SAMPLE
補足の開閉・表示切替を試せます
ALTERNATIVES
型番 process-flow
業務・システム導入の手順を一本の連続process laneとして描き、各工程の区間長を所要日数からデータ駆動で計算する。hand-off境界には開始からの累計日数を置き、hover・focusでは同じ工程の区間とレコードだけを穏やかに強調する。最も時間のかかる工程をlane上で自動強調し、補足は図の下で展開する
型番 conversion-funnel
同一母集団が段階的に絞られる様子を件数比例の連続漏斗として描く。件名は各断面の上、件数値と主要指標は断面線上へ置いて水平ガイドで結び、hover・focusでは同じ断面レコードだけを穏やかに強調する。最大離脱後の断面を1件常設強調し、同じ漏斗上で到達情報から離脱数・離脱率・順位へ切り替えられる
型番 logic-tree
親IDで結んだ可変階層をD3のtidy treeで幹・枝・葉へ配置し、葉の寄与値を親へ自動集計して細い枝の線幅へ反映する。同じnode identityを構造から葉ドライバーの寄与度へ切り替え、構造では曲線・直線・直角の枝を閲覧者が選び、最大寄与の祖先経路を1件強調する
import {
sankey,
sankeyLinkHorizontal,
type SankeyGraph,
type SankeyLink,
type SankeyNode,
} from "d3-sankey";
import { useId, type CSSProperties } from "react";
import type { SankeyEndpoint, SankeyFlowItem, SankeyFlowProps } from "./schema";
const SANKEY_WIDTH = 600;
const SANKEY_HEIGHT = 320;
type SankeyNodeDatum = {
key: string;
id: string;
label: string;
side: "source" | "target";
originalIndex: number;
};
type SankeyLinkDatum = SankeyFlowItem & {
source: string;
target: string;
originalIndex: number;
};
export type SankeyGeometryNode = SankeyNodeDatum & {
x0: number;
x1: number;
y0: number;
y1: number;
total: number;
};
export type SankeyGeometryLink = {
sourceId: string;
targetId: string;
value: number;
note?: string;
originalIndex: number;
sourceIndex: number;
targetIndex: number;
sourceY: number;
targetY: number;
width: number;
path: string;
};
export type SankeyGeometry = {
width: number;
height: number;
nodes: SankeyGeometryNode[];
links: SankeyGeometryLink[];
};
export function endpointTotals(
sources: readonly SankeyEndpoint[],
targets: readonly SankeyEndpoint[],
flows: readonly SankeyFlowItem[],
) {
const sourceTotals = Object.fromEntries(
sources.map((source) => [source.id, 0]),
) as Record<string, number>;
const targetTotals = Object.fromEntries(
targets.map((target) => [target.id, 0]),
) as Record<string, number>;
let total = 0;
for (const flow of flows) {
sourceTotals[flow.sourceId] =
(sourceTotals[flow.sourceId] ?? 0) + flow.value;
targetTotals[flow.targetId] =
(targetTotals[flow.targetId] ?? 0) + flow.value;
total += flow.value;
}
return { total, sources: sourceTotals, targets: targetTotals };
}
export function flowSharePct(value: number, total: number) {
return total === 0 ? 0 : (value / total) * 100;
}
export function rankedFlows<T extends SankeyFlowItem>(flows: readonly T[]) {
return flows
.map((flow, originalIndex) => ({ ...flow, originalIndex }))
.sort((a, b) => b.value - a.value || a.originalIndex - b.originalIndex);
}
export function sankeyGeometry(
sources: readonly SankeyEndpoint[],
targets: readonly SankeyEndpoint[],
flows: readonly SankeyFlowItem[],
): SankeyGeometry {
const nodes: SankeyNodeDatum[] = [
...sources.map((source, originalIndex) => ({
...source,
key: `source:${source.id}`,
side: "source" as const,
originalIndex,
})),
...targets.map((target, originalIndex) => ({
...target,
key: `target:${target.id}`,
side: "target" as const,
originalIndex,
})),
];
const links: SankeyLinkDatum[] = flows.map((flow, originalIndex) => ({
...flow,
source: `source:${flow.sourceId}`,
target: `target:${flow.targetId}`,
originalIndex,
}));
const layout = sankey<SankeyNodeDatum, SankeyLinkDatum>()
.nodeId((node) => node.key)
.nodeWidth(10)
.nodePadding(18)
.nodeSort((a, b) => a.originalIndex - b.originalIndex)
.extent([
[0, 4],
[SANKEY_WIDTH, SANKEY_HEIGHT - 4],
])
.iterations(24);
const graph = layout({
nodes: nodes.map((node) => ({ ...node })),
links: links.map((link) => ({ ...link })),
} as SankeyGraph<SankeyNodeDatum, SankeyLinkDatum>);
const pathFor = sankeyLinkHorizontal<SankeyNodeDatum, SankeyLinkDatum>();
return {
width: SANKEY_WIDTH,
height: SANKEY_HEIGHT,
nodes: graph.nodes.map((node) => ({
key: node.key,
id: node.id,
label: node.label,
side: node.side,
originalIndex: node.originalIndex,
x0: node.x0 as number,
x1: node.x1 as number,
y0: node.y0 as number,
y1: node.y1 as number,
total: node.value as number,
})),
links: graph.links.map((link) => {
const source = link.source as SankeyNode<
SankeyNodeDatum,
SankeyLinkDatum
>;
const target = link.target as SankeyNode<
SankeyNodeDatum,
SankeyLinkDatum
>;
return {
sourceId: link.sourceId,
targetId: link.targetId,
value: link.value,
note: link.note,
originalIndex: link.originalIndex,
sourceIndex: source.originalIndex,
targetIndex: target.originalIndex,
sourceY: link.y0 as number,
targetY: link.y1 as number,
width: link.width as number,
path:
pathFor(link as SankeyLink<SankeyNodeDatum, SankeyLinkDatum>) ?? "",
};
}),
};
}
const formatValue = (value: number) =>
new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 1 }).format(value);
const stagger = (index: number) => `calc(var(--stagger-step) * ${index})`;
const flowKey = (flow: Pick<SankeyFlowItem, "sourceId" | "targetId">) =>
`${flow.sourceId}-${flow.targetId}`;
const sankeyInteractionCss = (geometry: SankeyGeometry) =>
geometry.links
.map(
(link) => `
[data-sankey-map]:has([data-sankey-link-index="${link.originalIndex}"]:is(:hover, :focus, :active)) [data-sankey-visible-index="${link.originalIndex}"] {
stroke: var(--color-primary);
stroke-opacity: 1;
}
[data-sankey-map]:has([data-sankey-link-index="${link.originalIndex}"]:is(:hover, :focus, :active)) [data-sankey-side="source"][data-sankey-node-index="${link.sourceIndex}"],
[data-sankey-map]:has([data-sankey-link-index="${link.originalIndex}"]:is(:hover, :focus, :active)) [data-sankey-side="target"][data-sankey-node-index="${link.targetIndex}"] {
color: var(--color-tertiary);
}
[data-sankey-map]:has([data-sankey-link-index="${link.originalIndex}"]:is(:hover, :focus, :active)) .sankey-endpoint-rail[data-sankey-side="source"][data-sankey-node-index="${link.sourceIndex}"],
[data-sankey-map]:has([data-sankey-link-index="${link.originalIndex}"]:is(:hover, :focus, :active)) .sankey-endpoint-rail[data-sankey-side="target"][data-sankey-node-index="${link.targetIndex}"] {
fill: var(--color-primary);
fill-opacity: 1;
}
`,
)
.join("");
function DesktopFlowMap({
geometry,
totals,
unit,
gradientId,
priorityIndex,
sourceById,
targetById,
}: {
geometry: SankeyGeometry;
totals: ReturnType<typeof endpointTotals>;
unit: string;
gradientId: string;
priorityIndex: number;
sourceById: ReadonlyMap<string, SankeyEndpoint>;
targetById: ReadonlyMap<string, SankeyEndpoint>;
}) {
return (
<div
data-sankey-desktop="true"
data-sankey-map="true"
className="relative hidden h-80 sm:block"
>
<div className="absolute inset-y-0 left-44 right-44">
<svg
viewBox={`0 0 ${geometry.width} ${geometry.height}`}
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
role="img"
aria-label="起点から終点への流量図"
>
<defs>
<linearGradient id={gradientId} x1="0" x2="1">
<stop offset="0%" stopColor="var(--color-tertiary)" />
<stop offset="100%" stopColor="var(--color-accent-violet)" />
</linearGradient>
</defs>
{geometry.links.map((link) => {
const isPriority = link.originalIndex === priorityIndex;
const accessibleLabel = `${sourceById.get(link.sourceId)?.label} → ${targetById.get(link.targetId)?.label}: ${formatValue(link.value)} ${unit}${link.note ? `。${link.note}` : ""}`;
return (
<g
key={flowKey(link)}
data-sankey-flow={flowKey(link)}
data-sankey-source={link.sourceId}
data-sankey-target={link.targetId}
>
<title>{accessibleLabel}</title>
<path
data-sankey-link-index={link.originalIndex}
tabIndex={0}
role="img"
aria-label={accessibleLabel}
d={link.path}
fill="none"
stroke="transparent"
strokeWidth={Math.max(link.width, 18)}
pointerEvents="stroke"
className="cursor-pointer outline-none"
/>
<path
data-sankey-visible-index={link.originalIndex}
data-sankey-priority={isPriority ? "true" : undefined}
d={link.path}
fill="none"
stroke={
isPriority ? `url(#${gradientId})` : "var(--color-outline)"
}
strokeLinecap="butt"
strokeOpacity={isPriority ? 0.96 : 0.58}
strokeWidth={Math.max(link.width, 1)}
pointerEvents="none"
className={`sankey-visible-flow ${
isPriority ? "sankey-priority-flow" : ""
}`}
/>
</g>
);
})}
{geometry.nodes.map((node) => (
<rect
key={node.key}
data-sankey-endpoint-rail={node.id}
data-sankey-side={node.side}
data-sankey-node-index={node.originalIndex}
className="sankey-endpoint-rail"
x={node.x0}
y={node.y0}
width={Math.max(node.x1 - node.x0, 1)}
height={Math.max(node.y1 - node.y0, 1)}
rx={4}
fill="var(--color-outline)"
fillOpacity={0.82}
/>
))}
</svg>
</div>
{geometry.nodes.map((node) => {
const isSource = node.side === "source";
const nodeTotal = isSource
? totals.sources[node.id]
: totals.targets[node.id];
const anchor = {
top: `${((node.y0 + node.y1) / 2 / geometry.height) * 100}%`,
animationDelay: stagger(
node.originalIndex + (isSource ? 0 : geometry.nodes.length),
),
} as CSSProperties;
return (
<div
key={node.key}
data-sankey-endpoint={node.id}
data-sankey-side={node.side}
data-sankey-node-index={node.originalIndex}
className={`absolute z-10 w-40 -translate-y-1/2 transition-colors ${
isSource ? "left-0 text-left" : "right-0 text-right"
}`}
style={anchor}
>
<div className="animate-rise">
<span
aria-hidden="true"
className={`absolute top-1/2 w-4 border-t border-outline ${
isSource ? "-right-4" : "-left-4"
}`}
/>
<p className="font-mono text-label text-secondary">
{isSource ? "起点" : "終点"}
</p>
<p className="mt-0.5 text-label">{node.label}</p>
<p className="mt-1 font-mono text-h3">
{formatValue(nodeTotal ?? 0)}
<span className="ml-1 text-label">{unit}</span>
</p>
</div>
</div>
);
})}
</div>
);
}
function MobileFlowLedger({
sources,
flows,
totals,
maxFlow,
unit,
targetById,
priorityIndex,
}: {
sources: readonly SankeyEndpoint[];
flows: readonly SankeyFlowItem[];
totals: ReturnType<typeof endpointTotals>;
maxFlow: number;
unit: string;
targetById: ReadonlyMap<string, SankeyEndpoint>;
priorityIndex: number;
}) {
return (
<div
data-sankey-mobile-ledger="true"
className="rounded-lg bg-surface-subtle/50 p-4 sm:hidden"
>
<p className="mb-4 text-body-sm text-secondary">
起点ごとに分岐を整理。横railの長さは各流路の量を表します。
</p>
<div className="divide-y divide-outline">
{sources.map((source) => {
const sourceFlows = flows
.map((flow, originalIndex) => ({ ...flow, originalIndex }))
.filter((flow) => flow.sourceId === source.id);
return (
<section key={source.id} className="py-4 first:pt-0 last:pb-0">
<header className="mb-2 flex items-baseline justify-between gap-3">
<div className="min-w-0">
<p className="font-mono text-label text-secondary">起点</p>
<h2 className="break-words text-label">{source.label}</h2>
</div>
<p className="shrink-0 font-mono text-body-sm">
{formatValue(totals.sources[source.id] ?? 0)}
<span className="ml-1 text-label">{unit}</span>
</p>
</header>
<div className="ml-1 space-y-1 border-l border-outline pl-4">
{sourceFlows.map((flow) => {
const isPriority = flow.originalIndex === priorityIndex;
const width =
maxFlow === 0 ? 0 : (flow.value / maxFlow) * 100;
return (
<button
key={flowKey(flow)}
type="button"
data-sankey-mobile-flow={flowKey(flow)}
data-priority={isPriority ? "true" : undefined}
className="group/mobile-flow block w-full py-2 text-left transition-colors focus:text-tertiary focus:outline-none"
>
<span className="flex min-w-0 items-baseline justify-between gap-3">
<span className="min-w-0 break-words text-label">
→ {targetById.get(flow.targetId)?.label}
</span>
<span className="shrink-0 font-mono text-body-sm">
{formatValue(flow.value)}
<span className="ml-1 text-label">{unit}</span>
</span>
</span>
<span className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-outline/35">
<span
className={`block h-full rounded-full ${
isPriority
? "bg-gradient-to-r from-tertiary to-accent-violet"
: "bg-tertiary/55 group-focus/mobile-flow:bg-tertiary"
}`}
style={{ width: `${width}%` }}
/>
</span>
</button>
);
})}
</div>
</section>
);
})}
</div>
</div>
);
}
export default function SankeyFlow({
title,
subtitle,
unit = "",
sources,
targets,
flows,
instanceId,
}: SankeyFlowProps) {
const autoId = useId();
const uid = (instanceId ?? autoId).replace(/[^a-zA-Z0-9_-]/g, "") || "sankey";
const gradientId = `${uid}-flow-gradient`;
const totals = endpointTotals(sources, targets, flows);
const ranked = rankedFlows(flows);
const priority = ranked[0];
const priorityIndex = priority?.originalIndex ?? -1;
const sourceById = new Map(sources.map((source) => [source.id, source]));
const targetById = new Map(targets.map((target) => [target.id, target]));
const maxFlow = priority?.value ?? 0;
const geometry = sankeyGeometry(sources, targets, flows);
return (
<section className="group/sankey mx-auto max-w-slide bg-surface p-6 font-sans text-on-surface sm:p-12">
<style>{`
.sankey-visible-flow {
transition: stroke 140ms ease-out, stroke-opacity 140ms ease-out;
}
.sankey-endpoint-rail {
transition: fill 140ms ease-out, fill-opacity 140ms ease-out;
}
[data-sankey-map]:has([data-sankey-link-index]:is(:hover, :focus, :active)) .sankey-priority-flow {
stroke-opacity: 0.28;
}
${sankeyInteractionCss(geometry)}
`}</style>
<input
id={`${uid}-flow`}
name={`${uid}-view`}
type="radio"
defaultChecked
className="sankey-flow-view sr-only"
/>
<input
id={`${uid}-ranked`}
name={`${uid}-view`}
type="radio"
className="sankey-ranked-view 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}-flow`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.sankey-flow-view:checked]/sankey:bg-primary group-has-[.sankey-flow-view:checked]/sankey:text-on-primary"
>
流量図
</label>
<label
htmlFor={`${uid}-ranked`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.sankey-ranked-view:checked]/sankey:bg-primary group-has-[.sankey-ranked-view:checked]/sankey:text-on-primary"
>
流路ランキング
</label>
</div>
</header>
<div
data-testid="sankey-flow"
className="block group-has-[.sankey-ranked-view:checked]/sankey:hidden"
>
<DesktopFlowMap
geometry={geometry}
totals={totals}
unit={unit}
gradientId={gradientId}
priorityIndex={priorityIndex}
sourceById={sourceById}
targetById={targetById}
/>
<MobileFlowLedger
sources={sources}
flows={flows}
totals={totals}
maxFlow={maxFlow}
unit={unit}
targetById={targetById}
priorityIndex={priorityIndex}
/>
{priority && (
<div className="mt-4 grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1 border-y border-outline py-3 sm:grid-cols-[auto_1fr_auto] sm:gap-y-0">
<p className="font-mono text-label text-secondary">最大流路</p>
<p
data-sankey-summary-route="true"
className="col-span-2 row-start-2 min-w-0 break-words text-label sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:whitespace-nowrap"
>
{sourceById.get(priority.sourceId)?.label}
<span className="mx-2 text-secondary">→</span>
{targetById.get(priority.targetId)?.label}
</p>
<p className="col-start-2 row-start-1 font-mono text-h3 sm:col-start-3">
{formatValue(priority.value)}
<span className="ml-1 text-label">{unit}</span>
</p>
</div>
)}
</div>
<div
data-testid="sankey-ranked"
className="hidden group-has-[.sankey-ranked-view:checked]/sankey:block"
>
<div className="space-y-3">
{ranked.map((flow, index) => {
const isPriority = index === 0;
const width = maxFlow === 0 ? 0 : (flow.value / maxFlow) * 100;
return (
<article
key={flow.originalIndex}
data-priority={isPriority ? "true" : "false"}
className="animate-rise grid grid-cols-[auto_1fr_auto] items-center gap-4 rounded-lg border border-outline bg-surface p-4 shadow-card"
style={{ animationDelay: stagger(index) }}
>
<span
className={`flex size-9 items-center justify-center rounded-full font-mono text-label ${
isPriority
? "bg-gradient-to-br from-tertiary to-accent-violet text-on-primary"
: "bg-neutral text-secondary"
}`}
>
{String(index + 1).padStart(2, "0")}
</span>
<div>
<p className="text-label">
{sourceById.get(flow.sourceId)?.label}
<span className="mx-2 text-secondary">→</span>
{targetById.get(flow.targetId)?.label}
{isPriority && (
<span className="ml-2 font-mono text-label text-tertiary">
最大流路
</span>
)}
</p>
<div className="mt-2 h-2 rounded-full bg-surface-subtle">
<div
className={
isPriority
? "h-full rounded-full bg-gradient-to-r from-tertiary to-accent-violet"
: "h-full rounded-full bg-tertiary"
}
style={{ width: `${width}%` }}
/>
</div>
{flow.note && (
<p className="mt-2 text-body-sm text-secondary">
{flow.note}
</p>
)}
</div>
<p className="font-mono text-h3">
{formatValue(flow.value)}
<span className="ml-1 text-label">{unit}</span>
</p>
</article>
);
})}
</div>
</div>
</section>
);
}
import { z } from "zod";
export const sankeyEndpointSchema = z
.object({
id: z.string().min(1).describe("flowsから参照する一意な起点・終点ID"),
label: z.string().min(1).describe("部門、システム、費目などの表示名"),
})
.strict();
export const sankeyFlowSchema = z
.object({
sourceId: z.string().min(1).describe("sourcesに存在する起点ID"),
targetId: z.string().min(1).describe("targetsに存在する終点ID"),
value: z.number().positive().describe("正の流量。曲線幅と順位の唯一の出典"),
note: z.string().optional().describe("流れの定義、滞留要因、改善策など"),
})
.strict();
export const schema = z
.object({
title: z.string().describe("スライドタイトル。最大流路と判断を結論として書く"),
subtitle: z.string().optional().describe("対象期間、集計条件、出典など"),
unit: z.string().optional().describe("件/日、人月、百万円などの表示単位"),
sources: z.array(sankeyEndpointSchema).min(2).max(5).describe("流れの起点(2〜5件)"),
targets: z.array(sankeyEndpointSchema).min(2).max(5).describe("流れの終点(2〜5件)"),
flows: z.array(sankeyFlowSchema).min(2).max(12).describe("起点から終点への流量(2〜12件)"),
instanceId: z.string().min(1).optional().describe("表示切替radioの識別子"),
})
.strict()
.superRefine((data, ctx) => {
const sourceIds = new Set<string>();
data.sources.forEach((source, index) => {
if (sourceIds.has(source.id)) ctx.addIssue({ code: "custom", message: "source.id は一意にすること", path: ["sources", index, "id"] });
sourceIds.add(source.id);
});
const targetIds = new Set<string>();
data.targets.forEach((target, index) => {
if (targetIds.has(target.id)) ctx.addIssue({ code: "custom", message: "target.id は一意にすること", path: ["targets", index, "id"] });
targetIds.add(target.id);
});
const pairs = new Set<string>();
data.flows.forEach((flow, index) => {
if (!sourceIds.has(flow.sourceId)) ctx.addIssue({ code: "custom", message: `未知のsourceId: ${flow.sourceId}`, path: ["flows", index, "sourceId"] });
if (!targetIds.has(flow.targetId)) ctx.addIssue({ code: "custom", message: `未知のtargetId: ${flow.targetId}`, path: ["flows", index, "targetId"] });
const pair = `${flow.sourceId}\u0000${flow.targetId}`;
if (pairs.has(pair)) ctx.addIssue({ code: "custom", message: "同じ起点・終点の流路を重複指定できない", path: ["flows", index] });
pairs.add(pair);
});
});
export type SankeyEndpoint = z.infer<typeof sankeyEndpointSchema>;
export type SankeyFlowItem = z.infer<typeof sankeyFlowSchema>;
export type SankeyFlowProps = z.infer<typeof schema>;
{
"title": "問い合わせ流量 — 受注管理から経理への手作業転記が最大流路",
"subtitle": "平日1日あたり平均処理件数(2026年6月、20営業日)",
"unit": "件/日",
"sources": [
{ "id": "sales", "label": "受注管理" },
{ "id": "support", "label": "顧客サポート" },
{ "id": "ec", "label": "EC" }
],
"targets": [
{ "id": "erp", "label": "基幹ERP" },
{ "id": "accounting", "label": "経理手作業" },
{ "id": "analytics", "label": "分析基盤" }
],
"flows": [
{ "sourceId": "sales", "targetId": "erp", "value": 340, "note": "標準APIで15分ごとに自動連携" },
{ "sourceId": "sales", "targetId": "accounting", "value": 420, "note": "請求区分と費目を担当者が再入力。月約140時間" },
{ "sourceId": "sales", "targetId": "analytics", "value": 180, "note": "日次バッチで案件実績を連携" },
{ "sourceId": "support", "targetId": "erp", "value": 160, "note": "返品・交換情報を登録" },
{ "sourceId": "support", "targetId": "analytics", "value": 210, "note": "問い合わせ分類と満足度を連携" },
{ "sourceId": "ec", "targetId": "erp", "value": 380, "note": "注文・在庫引当をリアルタイム連携" },
{ "sourceId": "ec", "targetId": "analytics", "value": 260, "note": "行動・購買イベントを連携" }
]
}