Back to index

Waterfall Chart (Change Bridge)

Waterfall
Generates documents
Waterfall Chart (Change Bridge)

Seen as a design

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

waterfall-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

2.2 KB
Build an HTML document with "Waterfall Chart (Change Bridge)". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
The factors between a start and an end as stacked bars. The same data also switches to a ranking by contribution, which is what distinguishes it from a chart tool's built-in bridge

## What you have to supply
- The start and end values
- The difference each factor makes
- A note on each factor

## The shape of the input
- `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 のステップに付けると寄与度順ビューでは除外され消えるため、差分ステップに付けること(スキーマが拒否する)

schema.ts holds the machine-checkable validation. Ask rather than guess when an input is missing.

## When not to use it
- A single population narrowing stage by stage
- Listing several indicators against their targets

## How to build it
Put the input in a JSON file and run:

```bash
node scripts/render.mjs waterfall <input.json> --theme ink -o out.html
```

The output is one self-contained file and fetches nothing at render time.

## The source
- https://design-vault.pages.dev/designs/waterfall/source/template.tsx
- https://design-vault.pages.dev/designs/waterfall/source/schema.ts
- https://design-vault.pages.dev/designs/waterfall/source/sample-data.json
- https://design-vault.pages.dev/designs/waterfall/sample.html

Files

Template, input schema, sample data and metadata. This document was generated from those four. The generated document

template.tsx

11.1 KB

/designs/waterfall/source/template.tsx

Show contents
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} data-waterfall-record="true" 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.ts

2.4 KB

/designs/waterfall/source/schema.ts

Show contents
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>;

meta.yaml

3.2 KB

/designs/waterfall/source/meta.yaml

Show contents
id: waterfall
name:
  ja: "ウォーターフォールチャート(増減ブリッジ)"
  en: "Waterfall Chart (Change Bridge)"
purpose:
  ja: "起点から終点までの増減要因を累計配置の棒(ブリッジ)で示す。PowerPointのネイティブ滝グラフが自動化する「累計配置」だけでは差別化にならないため、同じデータを寄与度順(|増減|降順)の横棒ランキングに切り替えられる点が核。数値は常時表示し、補足は図の下で展開する"
  en: "The factors between a start and an end as stacked bars. The same data also switches to a ranking by contribution, which is what distinguishes it from a chart tool's built-in bridge"
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: breakdown
catalogOrder: 70
jobs:
  - measure-change
  - prioritize-diagnose
useCases:
  - kpi
layoutKinds:
  - waterfall
tags: [waterfall, bridge, breakdown, contribution, data-driven, interactive]
whenToUse:
  - ja: "四半期・年度の利益/売上ブリッジ(期首→期末の増減要因分解)"
    en: "A profit or revenue bridge across a quarter or a year"
  - ja: "コスト増減・原価変動の要因分析"
    en: "Analysing what moved a cost or a unit price"
  - ja: "施策・要因ごとの寄与度をランキングで示したい場合"
    en: "Ranking how much each factor contributed"
  - ja: "単なる推移ではなく「何が」「どれだけ」効いたかを説明したい場合"
    en: "When the point is what moved it and by how much, not the trend"
avoidWhen:
  - ja: "同一母集団が段階ごとに減少する転換を説明したい場合"
    en: "A single population narrowing stage by stage"
  - ja: "複数指標の現在値と目標達成を一覧したい場合"
    en: "Listing several indicators against their targets"
requiredInputs:
  - ja: "起点と終点の値"
    en: "The start and end values"
  - ja: "増減要因ごとの差分値"
    en: "The difference each factor makes"
  - ja: "各要因の補足"
    en: "A note on each factor"
relatedLayouts: [kpi-cards, conversion-funnel, logic-tree]
dataSchema:
  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 のステップに付けると寄与度順ビューでは除外され消えるため、差分ステップに付けること(スキーマが拒否する)"
sources: []

sample-data.json

1.3 KB

/designs/waterfall/source/sample-data.json

Show contents
{
  "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 }
  ]
}