BizLayout

型番 waterfall

ウォーターフォールチャート(増減ブリッジ)

起点から終点までの増減要因を累計配置の棒(ブリッジ)で示す。PowerPointのネイティブ滝グラフが自動化する「累計配置」だけでは差別化にならないため、同じデータを寄与度順(|増減|降順)の横棒ランキングに切り替えられる点が核。数値は常時表示し、補足は図の下で展開する

waterfall · bridge · breakdown · contribution · data-driven · interactive

LIVE SAMPLE

プレビュー

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

2026年度上期 営業利益ブリッジ — 原材料高騰が新規獲得の伸びを相殺し、営業利益は微減

期首(1Q)実績 → 上期の増減要因 → 期末(2Q)実績(単位: 百万円、経営会議資料サンプル)

補足を確認5

新規顧客獲得

+32 ・ 寄与度 32.7%

新規契約18社、うち7割が上位プラン。オンボーディング短縮の効果

既存顧客解約

-18 ・ 寄与度 18.4%

解約9社。うち6社は4月の価格改定前の駆け込み解約

原材料費高騰

-25 ・ 寄与度 25.5%

主要部材の仕入価格が四半期で18%上昇。ヘッジ未実施分が直撃した

価格改定効果

+14 ・ 寄与度 14.3%

4月改定の値上げ分。想定より浸透が早く計画超過

システム移行の一時費用

-9 ・ 寄与度 9.2%

基幹システム移行の一時費用。次期以降は発生しない

こういうときに使う

  • 四半期・年度の利益/売上ブリッジ(期首→期末の増減要因分解)
  • コスト増減・原価変動の要因分析
  • 施策・要因ごとの寄与度をランキングで示したい場合
  • 単なる推移ではなく「何が」「どれだけ」効いたかを説明したい場合

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。結論を書くメッセージラインを推奨
subtitle
string(任意)— 補足・出典など
steps
{ label, value, isTotal?, note?, highlight? } の配列。4〜7件が読みやすい(起点・終点を含む)
steps[].value
number — isTotal: true なら累計の絶対値(起点・終点・中間集計のアンカー)。false/省略なら直前の累計に加算される差分(正=増加、負=減少)
steps[].isTotal
boolean(任意)— true なら累計のリセット地点(primary色の構造バー)。省略時は差分(増加=tertiary/減少=secondary)
steps[].note
string(任意)— 図の下の補足欄で明示的に展開される詳細(要因・前提・出典)
steps[].highlight
boolean(任意)— 最重要の1件のみ true。isTotal のステップに付けると寄与度順ビューでは除外され消えるため、差分ステップに付けること(スキーマが拒否する)

サンプルコード

template.tsx267 行
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>
  );
}
schema.ts55 行
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>;
sample-data.json34 行
{
  "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 }
  ]
}