新規顧客獲得
+32 ・ 寄与度 32.7%
新規契約18社、うち7割が上位プラン。オンボーディング短縮の効果
型番 waterfall
起点から終点までの増減要因を累計配置の棒(ブリッジ)で示す。PowerPointのネイティブ滝グラフが自動化する「累計配置」だけでは差別化にならないため、同じデータを寄与度順(|増減|降順)の横棒ランキングに切り替えられる点が核。数値は常時表示し、補足は図の下で展開する
waterfall · bridge · breakdown · contribution · data-driven · interactive
LIVE SAMPLE
補足の開閉・表示切替を試せます
ALTERNATIVES
型番 kpi-cards
代表KPIの現在値・前期比・目標達成・推移を一つの主分析面で示し、残りを比較ledgerへ整理する。実データ系列から SVG スパークラインを自動生成し、目標を破線で重ねて達成/未達を自動判定する。解約率のような「下がるほど良い」指標も betterWhen で正しく着色される
型番 conversion-funnel
同一母集団が段階的に絞られる様子を件数比例の連続漏斗として描く。件名は各断面の上、件数値と主要指標は断面線上へ置いて水平ガイドで結び、hover・focusでは同じ断面レコードだけを穏やかに強調する。最大離脱後の断面を1件常設強調し、同じ漏斗上で到達情報から離脱数・離脱率・順位へ切り替えられる
型番 logic-tree
親IDで結んだ可変階層をD3のtidy treeで幹・枝・葉へ配置し、葉の寄与値を親へ自動集計して細い枝の線幅へ反映する。同じnode identityを構造から葉ドライバーの寄与度へ切り替え、構造では曲線・直線・直角の枝を閲覧者が選び、最大寄与の祖先経路を1件強調する
import { useId } from "react";
import type { WaterfallProps } from "./schema";
/** エントランスの時差。間隔は global.css の --stagger-step で統一 */
const stagger = (i: number, baseMs = 0) =>
`calc(${baseMs}ms + ${i} * var(--stagger-step))`;
type StepInput = { value: number; isTotal?: boolean };
export type BridgeBar = { start: number; end: number; isTotal: boolean };
/**
* 累計を各ステップの棒に写す。isTotal はその値を累計のリセット地点にする
* (位置は schema で強制しないため、途中にある isTotal は中間小計として自然に働く)
*/
export function bridgeBars(steps: StepInput[]): BridgeBar[] {
let acc = 0;
return steps.map((s) => {
if (s.isTotal) {
acc = s.value;
return { start: 0, end: s.value, isTotal: true };
}
const start = acc;
acc += s.value;
return { start, end: acc, isTotal: false };
});
}
/**
* 値域は必ず0を含める。total バーは「0からの高さ」で描く決定(構造バー)と
* 整合させるため — 0を含めないと value と表示高さのスケールが食い違う
*/
export function chartDomain(bars: BridgeBar[]) {
const values = bars.flatMap((b) => [b.start, b.end]);
const min = Math.min(0, ...values);
const max = Math.max(0, ...values);
return { min, max, span: max - min };
}
export function zeroLinePct(domain: { max: number; span: number }): number {
return domain.span === 0 ? 100 : (domain.max / domain.span) * 100;
}
/** span=0(空配列・全ゼロ)でも0除算しない。0ラインの位置を高さ0のバーとして返す */
export function barGeometry(
bar: BridgeBar,
domain: { max: number; span: number },
): { topPct: number; heightPct: number } {
if (domain.span === 0) return { topPct: zeroLinePct(domain), heightPct: 0 };
const top = domain.max - Math.max(bar.start, bar.end);
const height = Math.abs(bar.end - bar.start);
return { topPct: (top / domain.span) * 100, heightPct: (height / domain.span) * 100 };
}
/** 寄与度%の分母。total(アンカー)は「増減」ではないので除外する */
export function totalSwing(steps: StepInput[]): number {
return steps.filter((s) => !s.isTotal).reduce((sum, s) => sum + Math.abs(s.value), 0);
}
/** swing=0(差分が全部0、または差分ステップが無い)は0を返す(NaN/Infinity禁止) */
export function contributionPct(value: number, swing: number): number {
return swing === 0 ? 0 : (Math.abs(value) / swing) * 100;
}
/**
* 寄与度順ビュー用の並べ替え。total は除外。originalIndex は安定したキーとして残す
*/
export function rankedSteps<T extends StepInput>(
steps: T[],
): (T & { originalIndex: number })[] {
return steps
.map((s, originalIndex) => ({ ...s, originalIndex }))
.filter((s) => !s.isTotal)
.sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
}
const HIGHLIGHT_GRADIENT = "bg-linear-to-br from-tertiary to-accent-violet";
/**
* 色は「意味」でなく「構造/向き」を表す — total=primary(構造)、増加=tertiary、
* 減少=secondary。success/error は良し悪し専用(DESIGN.md)なのでここでは使わない
*/
export function barColorClass(step: {
isTotal?: boolean;
value: number;
highlight?: boolean;
}): string {
if (step.highlight) return HIGHLIGHT_GRADIENT;
if (step.isTotal) return "bg-primary";
if (step.value > 0) return "bg-tertiary";
if (step.value < 0) return "bg-secondary";
return "bg-secondary/40";
}
/** 描画専用の最小可視表示。生のgeometry/%は汚さない(process-flow の stepWidths と同じ発想) */
export const MIN_VISIBLE_PCT = 0.6;
export const visiblePct = (pct: number) => Math.max(pct, MIN_VISIBLE_PCT);
/** 差分値の符号付き表示。ブリッジと寄与度順の両方で使う */
const signedValue = (value: number) => `${value > 0 ? "+" : ""}${value}`;
export default function Waterfall({
title,
subtitle,
steps,
instanceId,
}: WaterfallProps) {
const autoId = useId();
const uid = instanceId ?? autoId;
const bars = bridgeBars(steps);
const domain = chartDomain(bars);
const swing = totalSwing(steps);
const ranked = rankedSteps(steps);
const zero = zeroLinePct(domain);
const notes = steps.filter((step) => step.note);
return (
<section
data-waterfall-root="true"
className="group/waterfall mx-auto max-w-slide bg-surface p-12 font-sans text-on-surface"
>
{/* 表示切替は CSS のみ(radio + :has())。成果物の単一ファイル制約を守る */}
<input
type="radio"
name={uid}
id={`${uid}-bridge`}
className="v-bridge sr-only"
defaultChecked
/>
<input type="radio" name={uid} id={`${uid}-ranked`} className="v-ranked sr-only" />
<header className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-h1">{title}</h1>
{subtitle && (
<p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
)}
</div>
<div className="inline-flex rounded-full border border-outline bg-surface p-1 shadow-card">
<label
htmlFor={`${uid}-bridge`}
className="cursor-pointer rounded-full px-4 py-1.5 text-label text-secondary group-has-[.v-bridge:checked]/waterfall:bg-primary group-has-[.v-bridge:checked]/waterfall:text-on-primary group-has-[.v-bridge:focus-visible]/waterfall:ring-2 group-has-[.v-bridge:focus-visible]/waterfall:ring-tertiary"
>
ブリッジ
</label>
<label
htmlFor={`${uid}-ranked`}
className="cursor-pointer rounded-full px-4 py-1.5 text-label text-secondary group-has-[.v-ranked:checked]/waterfall:bg-primary group-has-[.v-ranked:checked]/waterfall:text-on-primary group-has-[.v-ranked:focus-visible]/waterfall:ring-2 group-has-[.v-ranked:focus-visible]/waterfall:ring-tertiary"
>
寄与度順
</label>
</div>
</header>
{steps.length === 0 ? (
<p className="text-body-sm text-secondary">ステップが未設定</p>
) : (
<>
{/* ── ブリッジビュー: 累計配置の浮動バー ──
data-view はテスト専用フック。v-bridge/v-ranked は input・label・
このコンテナの3箇所に登場するため class 部分一致では一意に選べない */}
<div
data-view="bridge"
className="hidden group-has-[.v-bridge:checked]/waterfall:block"
>
<div className="relative h-72 rounded-lg border border-outline bg-surface">
<div
aria-hidden="true"
className="absolute inset-x-0 border-t border-dashed border-outline"
style={{ top: `${zero}%` }}
/>
<div className="flex h-full items-stretch">
{steps.map((step, i) => {
const geom = barGeometry(bars[i], domain);
return (
<div
key={i}
data-waterfall-step={i}
className="group/step relative flex-1 animate-pop px-1"
style={{ animationDelay: stagger(i, 400) }}
>
<div
className={`absolute inset-x-2 rounded-sm ${barColorClass(step)}`}
style={{
top: `${geom.topPct}%`,
height: `${visiblePct(geom.heightPct)}%`,
}}
/>
</div>
);
})}
</div>
</div>
<div className="mt-3 flex items-stretch">
{steps.map((step, i) => (
<div key={i} className="flex-1 px-1 text-center text-label text-secondary">
<p>{step.label}</p>
<p className="mt-1 font-mono">
{step.isTotal ? bars[i].end : signedValue(step.value)}
</p>
</div>
))}
</div>
</div>
{/* ── 寄与度順ビュー: total を除いた同じ steps を |value| 降順で並べ替え ── */}
<div
data-view="ranked"
className="hidden flex-col gap-3 group-has-[.v-ranked:checked]/waterfall:flex"
>
{ranked.length === 0 ? (
<p className="text-body-sm text-secondary">差分ステップがありません</p>
) : (
ranked.map((step, i) => {
const pct = contributionPct(step.value, swing);
return (
<div
key={step.originalIndex}
className="group/step animate-rise"
style={{ animationDelay: stagger(i) }}
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm">{step.label}</span>
<span className="shrink-0 text-label text-secondary">
{signedValue(step.value)}({pct.toFixed(1)}%)
</span>
</div>
<div className="mt-1 h-3 w-full rounded-full bg-surface-subtle">
<div
className={`h-full rounded-full ${barColorClass(step)}`}
style={{ width: `${visiblePct(pct)}%` }}
/>
</div>
</div>
);
})
)}
<p className="text-right text-label text-secondary">
増減の絶対値が大きい順。総変動に占める割合(%)を表示
</p>
</div>
</>
)}
{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">
{step.isTotal ? `累計 ${step.value}` : `${signedValue(step.value)} ・ 寄与度 ${contributionPct(step.value, swing).toFixed(1)}%`}
</p>
<p className="mt-2 text-body-sm">{step.note}</p>
</article>
))}
</div>
</details>
)}
</section>
);
}
import { z } from "zod";
export const waterfallStepSchema = z
.object({
label: z.string().describe("ステップ名(例: 期首実績、新規顧客獲得 など)"),
value: z
.number()
.describe(
"isTotal: true のときは累計の絶対値(起点・終点・中間集計のアンカー)。false/省略のときは直前の累計に加算される差分(正=増加、負=減少)",
),
isTotal: z
.boolean()
.optional()
.describe(
"true なら value を累計のリセット地点として扱う(起点・終点・中間小計のアンカー、primary色の構造バー)。省略時は差分ステップ(増加=tertiary/減少=secondary)",
),
note: z
.string()
.optional()
.describe("図の下の補足欄で明示的に展開される詳細(要因・前提・出典など)"),
highlight: z
.boolean()
.optional()
.describe(
"最重要の1件のみ true(グラデーション表示。DESIGN.md: 大胆さは1画面1箇所)。寄与度順ビューは isTotal のステップを除外するため、isTotal に highlight を付けると矛盾する",
),
})
.strict();
export const schema = z
.object({
title: z.string().describe("スライドタイトル。結論を書くメッセージラインを推奨"),
subtitle: z.string().optional().describe("補足・出典など"),
steps: z.array(waterfallStepSchema),
instanceId: z
.string()
.min(1)
.optional()
.describe(
"同一ページに複数インスタンスを置くときの表示切替トグルの識別子。空文字は不可 — radio の name が空文字だと同一グループとして扱われず表示切替が壊れる",
),
})
.strict()
.refine((data) => data.steps.filter((s) => s.highlight === true).length <= 1, {
message: "highlight は最大1件のみ true にすること(DESIGN.md: 大胆さは1画面1箇所)",
path: ["steps"],
})
.refine((data) => !data.steps.some((s) => s.highlight && s.isTotal), {
message:
"highlight は isTotal でないステップに付けること(isTotal は寄与度順ビューで除外されるため強調が消える)",
path: ["steps"],
});
export type WaterfallStep = z.infer<typeof waterfallStepSchema>;
export type WaterfallProps = z.infer<typeof schema>;
{
"title": "2026年度上期 営業利益ブリッジ — 原材料高騰が新規獲得の伸びを相殺し、営業利益は微減",
"subtitle": "期首(1Q)実績 → 上期の増減要因 → 期末(2Q)実績(単位: 百万円、経営会議資料サンプル)",
"steps": [
{ "label": "期首実績(1Q)", "value": 120, "isTotal": true },
{
"label": "新規顧客獲得",
"value": 32,
"note": "新規契約18社、うち7割が上位プラン。オンボーディング短縮の効果"
},
{
"label": "既存顧客解約",
"value": -18,
"note": "解約9社。うち6社は4月の価格改定前の駆け込み解約"
},
{
"label": "原材料費高騰",
"value": -25,
"highlight": true,
"note": "主要部材の仕入価格が四半期で18%上昇。ヘッジ未実施分が直撃した"
},
{
"label": "価格改定効果",
"value": 14,
"note": "4月改定の値上げ分。想定より浸透が早く計画超過"
},
{
"label": "システム移行の一時費用",
"value": -9,
"note": "基幹システム移行の一時費用。次期以降は発生しない"
},
{ "label": "期末実績(2Q)", "value": 114, "isTotal": true }
]
}