BizLayout

型番 sankey-flow

Sankeyフローマップ(多対多の流量)

複数の起点から複数の終点へ流れる量を、合計量比例の端点railと積層曲線として自動描画する。流路を辿ると対応する起点・終点・合計値が連動し、同じデータを流量図から流路ランキングへ切り替えられる

sankey · flow · allocation · routing · data-driven · interactive

LIVE SAMPLE

プレビュー

補足の開閉・表示切替を試せます

問い合わせ流量 — 受注管理から経理への手作業転記が最大流路

平日1日あたり平均処理件数(2026年6月、20営業日)

起点ごとに分岐を整理。横railの長さは各流路の量を表します。

起点

受注管理

940件/日

起点

顧客サポート

370件/日

起点

EC

640件/日

最大流路

受注管理経理手作業

420件/日

こういうときに使う

  • システム間データ連携や問い合わせの流れを量つきで示す場合
  • 予算、人員、工数が複数の配分先へ流れる構造を説明する場合
  • 直列の業務手順ではなく、分岐・合流する多対多の流れを扱う場合
  • 最大の流路、転記、滞留先を特定したい場合
  • 流路と左右の合計値を一つの記録として追いたい場合

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。最大流路を結論として書く
subtitle
string(任意)— 対象期間、集計条件、出典など
unit
string(任意)— 件/日、人月、百万円など
sources
{ id, label }[] — 流れの起点(2〜5件)
targets
{ id, label }[] — 流れの終点(2〜5件)
flows
{ sourceId, targetId, value, note? }[] — 正の流量(2〜12件)。同じ起点・終点の組み合わせは1件

サンプルコード

template.tsx603 行
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>
  );
}
schema.ts53 行
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>;
sample-data.json24 行
{
  "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": "行動・購買イベントを連携" }
  ]
}