Seen as a design The generated document itself — its toggles and disclosures work here
Seen as a design The generated document itself — its toggles and disclosures work here
Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.
Everything in this panel, including the contents behind each URL, in one Markdown file — Large, so it goes as a file (copy is for 32 KB and under)
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 Template, input schema, sample data and metadata. This document was generated from those four. The generated document
/designs/decision-tree/source/template.tsx
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>
);
}
/designs/decision-tree/source/schema.ts
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>;
/designs/decision-tree/source/meta.yaml
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: []
/designs/decision-tree/source/sample-data.json
{
"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"
}