Back to index

Decision Tree (Expected Value)

Tree
Generates documents
Decision Tree (Expected Value)

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

decision-tree-everything.md

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

Prompt

1.3 KB
Build an HTML document with "Decision Tree (Expected Value)". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
Probability branches and outcome values per option, resolved into an expected value, with the recommended option and the path that justifies it

## What you have to supply
- Two to four options
- The probability branches under each
- The value of each outcome

## The shape of the input
- `options` — { id, label, branches[] }[] — 2〜4選択肢
- `branches` — { id, label, probability, outcomeValue, note? }[] — 各2〜5結果、確率合計100

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

## When not to use it
- Decomposing causes into parent and child
- Showing a date-driven parallel plan

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

```bash
node scripts/render.mjs decision-tree <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/decision-tree/source/template.tsx
- https://design-vault.pages.dev/designs/decision-tree/source/schema.ts
- https://design-vault.pages.dev/designs/decision-tree/source/sample-data.json
- https://design-vault.pages.dev/designs/decision-tree/sample.html

Files

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

template.tsx

14.0 KB

/designs/decision-tree/source/template.tsx

Show contents
import { useId } from "react";
import type { DecisionOption, DecisionTreeProps } from "./schema";

const round = (value: number) => Number(value.toFixed(2));

// 2 つの識別子を `-` で繋ぐと単射にならない —— ("a-b","c") と ("a","b-c") が
// 同じ鍵になり、React の key が重複する。区切りに使う文字を各辺で逃がしてから繋ぐ。
const joinIds = (...parts: (string | number)[]) =>
  parts.map((p) => String(p).replace(/[_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`)).join("-");

export function computeExpectedValues(options: DecisionOption[]) {
  const computed = options.map((option, index) => ({
    ...option,
    expectedValue: Number(
      option.branches
        .reduce(
          (sum, branch) =>
            sum + (branch.probability / 100) * branch.outcomeValue,
          0,
        )
        .toFixed(2),
    ),
    inputIndex: index,
  }));
  const best = [...computed].sort(
    (a, b) => b.expectedValue - a.expectedValue || a.inputIndex - b.inputIndex,
  )[0];
  return computed.map((option) => ({
    ...option,
    recommended: option.id === best?.id,
  }));
}

export function decisionTreeGeometry(options: DecisionOption[]) {
  const computed = computeExpectedValues(options);
  const maxExpected = Math.max(
    1,
    ...computed.map((option) => Math.abs(option.expectedValue)),
  );
  const maxOutcome = Math.max(
    1,
    ...computed.flatMap((option) =>
      option.branches.map((branch) => Math.abs(branch.outcomeValue)),
    ),
  );
  const optionSpan = computed.length === 1 ? 0 : 280 / (computed.length - 1);
  const optionNodes = computed.map((option, optionIndex) => ({
    ...option,
    x: 350,
    y: round(100 + optionIndex * optionSpan),
    size: round(24 + (Math.abs(option.expectedValue) / maxExpected) * 10),
  }));
  const outcomes = optionNodes.flatMap((option) => {
    const count = option.branches.length;
    const spread = count <= 1 ? 0 : Math.min(68, 96 / (count - 1));
    return option.branches.map((branch, branchIndex) => ({
      ...branch,
      id: joinIds(option.id, branch.id),
      optionId: option.id,
      optionLabel: option.label,
      x: 760,
      y: round(option.y + (branchIndex - (count - 1) / 2) * spread),
      radius: round(10 + Math.sqrt(Math.abs(branch.outcomeValue) / maxOutcome) * 18),
    }));
  });
  const rootEdges = optionNodes.map((option) => ({
    id: `root-${option.id}`,
    target: option,
    strokeWidth: round(1.4 + Math.max(0, option.expectedValue) / maxExpected),
  }));
  const branchEdges = outcomes.map((outcome) => ({
    id: joinIds(outcome.optionId, outcome.id),
    source: optionNodes.find((option) => option.id === outcome.optionId)!,
    target: outcome,
    strokeWidth: round(1 + outcome.probability * 0.05),
  }));
  return { options: optionNodes, outcomes, rootEdges, branchEdges };
}

export default function DecisionTree({
  title,
  subtitle,
  valueUnit,
  options,
  instanceId,
}: DecisionTreeProps) {
  // 既定を固定文字列にすると、id を指定せず 2 つ置いたときに radio の name と
  // SVG marker の id を共有してしまう。他の型と同じく useId() に寄せる。
  const autoId = useId();
  const uid = (instanceId ?? autoId).replace(
      // 逃がし文字(`_`)自身も逃がすので単射。捨てる形にすると `a:b` と `a/b` が
      // 同じ id になる。
      /[^a-zA-Z0-9-]/g,
      (c: string) => `_${c.charCodeAt(0).toString(16)}_`,
    ) || "decision-tree";
  const computed = computeExpectedValues(options);
  const best = computed.find((option) => option.recommended)!;
  const notes = options.flatMap((option) =>
    option.branches
      .filter((branch) => branch.note)
      .map((branch) => ({ option: option.label, ...branch })),
  );
  const geometry = decisionTreeGeometry(options);
  const markerId = `${uid}-probability-arrow`;

  return (
    <section
      className="asset group/decision overflow-hidden rounded-xl border border-outline bg-surface p-5 text-primary shadow-card sm:p-8"
      data-layout="decision-tree"
    >
      <header className="border-b border-outline pb-5">
        <p className="font-mono text-label text-tertiary">PROBABILITY SPINE</p>
        <h1 className="mt-2 text-h1 text-balance">{title}</h1>
        {subtitle && <p className="mt-2 text-body text-secondary">{subtitle}</p>}
      </header>

      <div className="mt-5 flex flex-wrap justify-between gap-3">
        <div className="inline-flex rounded-full border border-outline bg-surface-subtle p-1 text-label">
          <label className="rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="decision-view-tree sr-only" type="radio" name={`${uid}-view`} defaultChecked />分岐
          </label>
          <label className="rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="decision-view-ledger sr-only" type="radio" name={`${uid}-view`} />期待値
          </label>
        </div>
        <span className="font-mono text-label text-secondary">LINE=PROBABILITY · CIRCLE=OUTCOME · DIAMOND=OPTION</span>
      </div>

      <div
        className="mt-5 grid gap-2 border-l-4 border-tertiary bg-gradient-to-r from-tertiary/20 to-transparent px-4 py-3 sm:grid-cols-[auto_1fr_auto] sm:items-center"
        data-decision-default-priority="true"
      >
        <span className="font-mono text-label text-tertiary">RECOMMENDED</span>
        <strong>{best.label}</strong>
        <span className="font-mono text-h3">EV {best.expectedValue}{valueUnit}</span>
      </div>

      <div data-decision-view="tree" className="mt-6 group-has-[.decision-view-ledger:checked]/decision:hidden">
        <svg
          className="hidden h-96 w-full sm:block"
          viewBox="0 0 1000 460"
          role="img"
          aria-label="意思決定点から選択肢と確率別結果へ分岐する決定木"
          data-primary-geometry="decision-tree"
          data-geometry-channels="expected-value option-size probability-width outcome-radius outcome-sign"
        >
          <defs>
            <marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto">
              <path d="M 0 0 L 10 5 L 0 10 z" className="fill-primary" />
            </marker>
          </defs>

          <line x1="70" x2="70" y1="42" y2="418" className="stroke-outline" vectorEffect="non-scaling-stroke" />
          <circle cx="70" cy="230" r="13" className="fill-primary" />
          <text x="70" y="28" textAnchor="middle" className="fill-secondary font-mono text-label">DECISION</text>
          <text x="350" y="28" textAnchor="middle" className="fill-secondary font-mono text-label">OPTIONS / EV</text>
          <text x="760" y="28" textAnchor="middle" className="fill-secondary font-mono text-label">OUTCOMES / P</text>

          {geometry.rootEdges.map((edge) => {
            const endX = edge.target.x - edge.target.size - 10;
            return (
              <path
                key={edge.id}
                d={`M 83 230 C 180 230, 220 ${edge.target.y}, ${endX} ${edge.target.y}`}
                fill="none"
                className={edge.target.recommended ? "stroke-tertiary" : "stroke-primary"}
                strokeWidth={edge.strokeWidth}
                vectorEffect="non-scaling-stroke"
                markerEnd={`url(#${markerId})`}
                data-primary-edge={edge.id}
              />
            );
          })}

          {geometry.branchEdges.map((edge) => {
            const startX = edge.source.x + edge.source.size + 8;
            const endX = edge.target.x - edge.target.radius - 10;
            const controlX = (startX + endX) / 2;
            return (
              <path
                key={edge.id}
                d={`M ${startX} ${edge.source.y} C ${controlX} ${edge.source.y}, ${controlX} ${edge.target.y}, ${endX} ${edge.target.y}`}
                fill="none"
                className={edge.source.recommended ? "stroke-tertiary" : "stroke-primary"}
                strokeWidth={edge.strokeWidth}
                vectorEffect="non-scaling-stroke"
                markerEnd={`url(#${markerId})`}
                data-primary-edge={edge.id}
              />
            );
          })}

          {geometry.options.map((option, index) => {
            const isPriority = option.recommended;
            const points = `${option.x},${option.y - option.size} ${option.x + option.size},${option.y} ${option.x},${option.y + option.size} ${option.x - option.size},${option.y}`;
            return (
              <g
                key={option.id}
                tabIndex={0}
                data-decision-option={option.id}
                data-primary-record={option.id}
                data-geometry-a={option.expectedValue}
                data-geometry-b={option.y}
                data-primary-emphasis={isPriority ? "true" : undefined}
              >
                {isPriority && <circle cx={option.x} cy={option.y} r={option.size + 13} className="fill-tertiary/15" />}
                <polygon points={points} className={isPriority ? "fill-tertiary" : "fill-primary"} />
                <text data-primary-label={option.id} x={option.x} y={option.y - option.size - 10} textAnchor="middle" className="fill-primary font-mono text-label font-semibold">{String(index + 1).padStart(2, "0")}</text>
              </g>
            );
          })}

          {geometry.outcomes.map((outcome) => {
            const positive = outcome.outcomeValue >= 0;
            return (
              <g
                key={outcome.id}
                tabIndex={0}
                data-primary-record={outcome.id}
                data-geometry-a={outcome.outcomeValue}
                data-geometry-b={outcome.probability}
                aria-label={`${outcome.label}: ${outcome.probability}% · ${outcome.outcomeValue}${valueUnit}`}
              >
                <circle cx={outcome.x} cy={outcome.y} r={outcome.radius} className={positive ? "fill-primary" : "fill-tertiary"} />
              </g>
            );
          })}
        </svg>

        <div className="decision-desktop-ledger mt-4 hidden divide-y divide-outline border-y border-outline sm:block">
          {computed.map((option, optionIndex) => (
            <article key={option.id} data-decision-desktop-option={option.id} data-decision-option={option.id} data-primary-record={option.id} className="grid grid-cols-[3rem_minmax(0,1fr)] gap-3 py-3">
              <span className="font-mono text-label text-tertiary">{String(optionIndex + 1).padStart(2, "0")}</span>
              <div className="min-w-0 break-words">
                <div className="flex flex-wrap justify-between gap-x-4"><strong data-primary-label={option.id}>{option.label}</strong><span className="font-mono text-label">EV {option.expectedValue}{valueUnit}{option.recommended ? " · 推奨" : ""}</span></div>
                <div className="mt-2 divide-y divide-outline/60">
                  {option.branches.map((branch, branchIndex) => (
                    <p key={branch.id} data-decision-desktop-outcome={branch.id} className="py-2 text-label text-secondary"><span className="mr-2 font-mono text-tertiary">O{String(geometry.outcomes.findIndex((outcome) => outcome.id === joinIds(option.id, branch.id)) + 1).padStart(2, "0")}</span><strong className="text-primary">{branch.label}</strong><span> · {branch.probability}% · {branch.outcomeValue > 0 ? "+" : ""}{branch.outcomeValue}{valueUnit}</span></p>
                  ))}
                </div>
              </div>
            </article>
          ))}
        </div>

        <div className="relative space-y-4 pl-7 sm:hidden">
          <span className="absolute bottom-2 left-2 top-2 w-px bg-outline" aria-hidden="true" />
          {computed.map((option, index) => (
            <article key={option.id} data-decision-option={option.id} data-decision-mobile-option={option.id} className={`relative py-3 pl-3 ${option.recommended ? "border-l-4 border-tertiary" : "border-b border-outline"}`}>
              <span className={`absolute -left-6 top-4 h-3 w-3 rotate-45 ${option.recommended ? "bg-tertiary ring-4 ring-tertiary/20" : "bg-primary"}`} aria-hidden="true" />
              <div className="flex justify-between gap-3"><strong>{String(index + 1).padStart(2, "0")} · {option.label}</strong><span className="font-mono text-label">EV {option.expectedValue}</span></div>
              <p className="mt-2 text-label text-secondary">{option.branches.map((branch) => `${branch.label} ${branch.probability}% / ${branch.outcomeValue}`).join(" · ")}</p>
            </article>
          ))}
        </div>
      </div>

      <div data-decision-view="evidence-ledger" className="mt-6 hidden group-has-[.decision-view-ledger:checked]/decision:block">
        <div className="divide-y divide-outline border-y border-outline">
          {[...computed].sort((a, b) => b.expectedValue - a.expectedValue).map((option, index) => (
            <article key={option.id} data-decision-option={option.id} className={`grid grid-cols-[3rem_1fr_auto] items-center gap-3 py-3 ${option.recommended ? "border-l-4 border-tertiary pl-3" : "pl-4"}`}>
              <span className="font-mono text-label text-secondary">{String(index + 1).padStart(2, "0")}</span>
              <div><strong>{option.label}</strong><p className="text-label text-secondary">{option.branches.map((branch) => `${branch.probability}%×${branch.outcomeValue}`).join(" + ")}</p></div>
              <span className="font-mono text-h3">{option.expectedValue}</span>
            </article>
          ))}
        </div>
      </div>

      {notes.length > 0 && (
        <details data-inline-disclosure className="mt-6 rounded-lg border border-outline bg-surface-subtle p-4">
          <summary className="cursor-pointer font-semibold">補足を確認({notes.length}件)</summary>
          {notes.map((branch) => <p key={joinIds(branch.option, branch.id)} data-note-item="true" className="mt-3 text-body text-secondary"><strong className="text-primary">{branch.option} / {branch.label}:</strong> {branch.note}</p>)}
        </details>
      )}
    </section>
  );
}

schema.ts

1.7 KB

/designs/decision-tree/source/schema.ts

Show contents
import { z } from "zod";
export const decisionBranchSchema = z.object({ id: z.string().min(1), label: z.string().min(1), probability: z.number().min(0).max(100), outcomeValue: z.number(), note: z.string().optional() }).strict();
export const decisionOptionSchema = z.object({ id: z.string().min(1), label: z.string().min(1), branches: z.array(decisionBranchSchema).min(2).max(5) }).strict();
export const schema = z.object({ title: z.string().min(1), subtitle: z.string().optional(), valueUnit: z.string().min(1), options: z.array(decisionOptionSchema).min(2).max(4), instanceId: z.string().optional() }).strict().superRefine((value, ctx) => {
  // id は identity として使う(React の key、data 属性、期待値の突き合わせ)。
  // 重複すると computeExpectedValues が複数の選択肢を同時に推奨として立てる。
  const optionIds = value.options.map((option) => option.id);
  if (new Set(optionIds).size !== optionIds.length) {
    ctx.addIssue({ code: "custom", path: ["options"], message: "option ids must be unique" });
  }
  value.options.forEach((option, index) => {
    const branchIds = option.branches.map((branch) => branch.id);
    if (new Set(branchIds).size !== branchIds.length) {
      ctx.addIssue({ code: "custom", path: ["options", index, "branches"], message: "branch ids must be unique" });
    }
    const total = option.branches.reduce((sum, branch) => sum + branch.probability, 0);
    if (Math.abs(total - 100) > 0.001) ctx.addIssue({ code: "custom", path: ["options", index, "branches"], message: "branch probabilities must total 100" });
  });
});
export type DecisionOption = z.infer<typeof decisionOptionSchema>;
export type DecisionTreeProps = z.infer<typeof schema>;

meta.yaml

1.7 KB

/designs/decision-tree/source/meta.yaml

Show contents
id: decision-tree
name:
  ja: "デシジョンツリー(期待値選択)"
  en: "Decision Tree (Expected Value)"
purpose:
  ja: "選択肢ごとの確率分岐と結果価値から期待値を算出し、推奨選択肢とその根拠経路を示す"
  en: "Probability branches and outcome values per option, resolved into an expected value, with the recommended option and the path that justifies it"
# A5 の裁定で draft として移設し、2026-09-04 に型ごとの承認を取って公開した。
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: decision
catalogOrder: 180
jobs:
  - compare-choose
  - prioritize-diagnose
useCases:
  - comparison
layoutKinds:
  - tree
tags: [decision-tree, probability, expected-value, scenario]
whenToUse:
  - ja: "不確実な選択肢を期待値で比較したい場合"
    en: "Comparing uncertain options by expected value"
  - ja: "シナリオ前提と推奨判断を同時に説明したい場合"
    en: "Explaining the scenario assumptions and the recommendation together"
avoidWhen:
  - ja: "原因を親子へ分解したい場合"
    en: "Decomposing causes into parent and child"
  - ja: "日付依存の並行計画を示したい場合"
    en: "Showing a date-driven parallel plan"
requiredInputs:
  - ja: "2〜4選択肢"
    en: "Two to four options"
  - ja: "各選択肢の確率分岐"
    en: "The probability branches under each"
  - ja: "各結果の価値"
    en: "The value of each outcome"
relatedLayouts: [logic-tree, comparison-table, matrix-2x2]
dataSchema:
  options: "{ id, label, branches[] }[] — 2〜4選択肢"
  branches: "{ id, label, probability, outcomeValue, note? }[] — 各2〜5結果、確率合計100"
sources: []

sample-data.json

904 B

/designs/decision-tree/source/sample-data.json

Show contents
{
  "title": "限定導入を推奨 — 期待値59百万円で全面展開を上回る",
  "subtitle": "新サービス市場投入のシナリオ仮説",
  "valueUnit": "百万円",
  "options": [
    {"id":"pilot","label":"限定導入","branches":[{"id":"success","label":"市場適合","probability":70,"outcomeValue":80,"note":"重点2業種で有償導入へ進む想定"},{"id":"slow","label":"伸び悩み","probability":30,"outcomeValue":10}]},
    {"id":"full","label":"全面展開","branches":[{"id":"success","label":"急成長","probability":45,"outcomeValue":180},{"id":"fail","label":"撤退","probability":55,"outcomeValue":-70}]},
    {"id":"partner","label":"共同販売","branches":[{"id":"success","label":"販路獲得","probability":60,"outcomeValue":95},{"id":"slow","label":"調整長期化","probability":40,"outcomeValue":-5}]}
  ],
  "instanceId": "market-entry-decision"
}