調達単価最大寄与
140百万円
型番 logic-tree
親IDで結んだ可変階層をD3のtidy treeで幹・枝・葉へ配置し、葉の寄与値を親へ自動集計して細い枝の線幅へ反映する。同じnode identityを構造から葉ドライバーの寄与度へ切り替え、構造では曲線・直線・直角の枝を閲覧者が選び、最大寄与の祖先経路を1件強調する
logic-tree · driver-tree · decomposition · hierarchy · data-driven · interactive
LIVE SAMPLE
補足の開閉・表示切替を試せます
ALTERNATIVES
型番 pyramid
ビジョン・施策・実行タスクを、重み比率から導出した3層の地層断面として示す。ピラミッド帯と階層レコードは同じ上下境界を共有し、hover・focusで対応する1層だけを追える
型番 waterfall
起点から終点までの増減要因を累計配置の棒(ブリッジ)で示す。PowerPointのネイティブ滝グラフが自動化する「累計配置」だけでは差別化にならないため、同じデータを寄与度順(|増減|降順)の横棒ランキングに切り替えられる点が核。数値は常時表示し、補足は図の下で展開する
型番 critical-path-gantt
タスクを実日付の期間として配置し、期間バー終端から依存先の開始点へ細い依存線を結ぶ。最長経路は全体計画上の連続線と期間比例laneで示し、390pxでは同じ依存graphを縦向きledgerへ転置する
import { hierarchy, tree as d3Tree } from "d3-hierarchy";
import { line, linkHorizontal } from "d3-shape";
import { useId } from "react";
import type { CSSProperties } from "react";
import type { LogicNode, LogicTreeProps } from "./schema";
export type TreeNode = LogicNode & {
originalIndex: number;
children: TreeNode[];
};
export type TreeGeometryNode = {
id: string;
parentId?: string;
label: string;
note?: string;
originalIndex: number;
depth: number;
aggregate: number;
share: number;
centerY: number;
x: number;
recordWidth: number;
relatedTargetIndices: number[];
childIds: string[];
};
export type TreeGeometryEdge = {
parentId: string;
childId: string;
share: number;
strokeWidth: number;
isPriorityPath: boolean;
relatedTargetIndices: number[];
};
export type TreeGeometry = {
total: number;
maxDepth: number;
priorityId?: string;
priorityPathIds: string[];
nodes: TreeGeometryNode[];
edges: TreeGeometryEdge[];
};
export function buildTree(nodes: readonly LogicNode[]): TreeNode {
const built = new Map(
nodes.map((node, originalIndex) => [
node.id,
{ ...node, originalIndex, children: [] as TreeNode[] },
]),
);
let root: TreeNode | undefined;
for (const node of nodes) {
const current = built.get(node.id) as TreeNode;
if (node.parentId) built.get(node.parentId)?.children.push(current);
else root = current;
}
if (!root) throw new Error("logic-tree root is missing");
return root;
}
export function aggregateValue(node: TreeNode): number {
if (node.children.length === 0) return node.value ?? 0;
return node.children.reduce((sum, child) => sum + aggregateValue(child), 0);
}
function flattenTree(node: TreeNode): TreeNode[] {
return [node, ...node.children.flatMap(flattenTree)];
}
export function rankedLeaves(nodes: readonly LogicNode[]) {
return flattenTree(buildTree(nodes))
.filter((node) => node.children.length === 0)
.sort(
(a, b) =>
(b.value ?? 0) - (a.value ?? 0) || a.originalIndex - b.originalIndex,
);
}
const pathToRoot = (
node: TreeNode,
nodeById: ReadonlyMap<string, TreeNode>,
) => {
const path: string[] = [];
let current: TreeNode | undefined = node;
while (current) {
path.unshift(current.id);
current = current.parentId ? nodeById.get(current.parentId) : undefined;
}
return path;
};
const descendantIds = (node: TreeNode): string[] => [
node.id,
...node.children.flatMap(descendantIds),
];
export function treeGeometry(nodes: readonly LogicNode[]): TreeGeometry {
const root = buildTree(nodes);
const flat = flattenTree(root);
const nodeById = new Map(flat.map((node) => [node.id, node]));
const total = aggregateValue(root);
const priority = rankedLeaves(nodes)[0];
const priorityPathIds = priority ? pathToRoot(priority, nodeById) : [];
const depthById = new Map<string, number>();
const assignDepth = (node: TreeNode, depth: number) => {
depthById.set(node.id, depth);
node.children.forEach((child) => assignDepth(child, depth + 1));
};
assignDepth(root, 0);
const maxDepth = Math.max(...depthById.values());
const columnWidth = 100 / (maxDepth + 1);
const laidOut = d3Tree<TreeNode>()
.size([84, Math.max(maxDepth, 1)])
.separation((a, b) => (a.parent === b.parent ? 1 : 1.3))(
hierarchy(root, (node) => node.children),
);
const centerById = new Map(
laidOut.descendants().map((node) => [node.data.id, 8 + node.x]),
);
const relatedByTargetId = new Map<string, Set<string>>();
for (const target of flat) {
const related = new Set(pathToRoot(target, nodeById));
if (target.children.length > 0) {
descendantIds(target).forEach((id) => related.add(id));
}
relatedByTargetId.set(target.id, related);
}
const relatedTargetIndices = (elementId: string) =>
flat
.filter((target) => relatedByTargetId.get(target.id)?.has(elementId))
.map((target) => target.originalIndex);
const geometryNodes = flat.map<TreeGeometryNode>((node) => {
const depth = depthById.get(node.id) ?? 0;
const x = depth * columnWidth + 2;
return {
id: node.id,
parentId: node.parentId,
label: node.label,
note: node.note,
originalIndex: node.originalIndex,
depth,
aggregate: aggregateValue(node),
share: total === 0 ? 0 : aggregateValue(node) / total,
centerY: centerById.get(node.id) ?? 50,
x,
recordWidth: depth === maxDepth ? 100 - x - 2 : columnWidth * 0.56,
relatedTargetIndices: relatedTargetIndices(node.id),
childIds: node.children.map((child) => child.id),
};
});
const edges = geometryNodes
.filter(
(node): node is TreeGeometryNode & { parentId: string } =>
node.parentId !== undefined,
)
.map<TreeGeometryEdge>((node) => ({
parentId: node.parentId,
childId: node.id,
share: node.share,
strokeWidth: Math.min(4.25, 1.25 + Math.max(0, node.share) * 6),
isPriorityPath: priorityPathIds.includes(node.id) && node.id !== root.id,
relatedTargetIndices: relatedTargetIndices(node.id),
}));
return {
total,
maxDepth,
priorityId: priority?.id,
priorityPathIds,
nodes: geometryNodes,
edges,
};
}
const formatValue = (value: number) =>
new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 1 }).format(value);
const stagger = (index: number) => `calc(var(--stagger-step) * ${index})`;
const relationTokens = (indices: readonly number[]) => indices.join(" ");
const nodeRole = (node: TreeGeometryNode) =>
node.depth === 0 ? "ROOT" : node.childIds.length > 0 ? "BRANCH" : "DRIVER";
type LinkPoint = [number, number];
type LinkDatum = { source: LinkPoint; target: LinkPoint };
const curvedLink = linkHorizontal<LinkDatum, LinkPoint>();
const straightLink = line<LinkPoint>();
const readablePath = (path: string | null) =>
(path ?? "")
.replace(/([A-Za-z])/g, " $1 ")
.replaceAll(",", " ")
.replace(/\s+/g, " ")
.trim();
const interactionCss = (geometry: TreeGeometry) =>
geometry.nodes
.map(
(target) => `
[data-logic-tree]:has([data-logic-hotspot-index="${target.originalIndex}"]:is(:hover, :focus, :active)) [data-logic-record][data-logic-related~="${target.originalIndex}"] {
color: var(--color-tertiary);
opacity: 1;
}
[data-logic-tree]:has([data-logic-hotspot-index="${target.originalIndex}"]:is(:hover, :focus, :active)) .logic-weighted-edge[data-logic-related~="${target.originalIndex}"] {
stroke: var(--color-primary);
stroke-opacity: 1;
}
[data-logic-tree]:has([data-logic-hotspot-index="${target.originalIndex}"]:is(:hover, :focus, :active)) .logic-node-port[data-logic-related~="${target.originalIndex}"] {
fill: var(--color-primary);
stroke: var(--color-primary);
opacity: 1;
}
[data-logic-tree]:has([data-logic-hotspot-index="${target.originalIndex}"]:is(:hover, :focus, :active)) .logic-mobile-rail[data-logic-related~="${target.originalIndex}"] {
background: var(--color-tertiary);
}
`,
)
.join("");
function DesktopTree({
geometry,
unit,
gradientId,
}: {
geometry: TreeGeometry;
unit: string;
gradientId: string;
}) {
const byId = new Map(geometry.nodes.map((node) => [node.id, node]));
return (
<div
data-logic-tree="desktop"
data-logic-layout-engine="d3-tree"
className="relative hidden h-[38rem] overflow-hidden rounded-lg bg-surface-subtle/50 sm:block"
>
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full overflow-visible"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="var(--color-tertiary)" />
<stop offset="1" stopColor="var(--color-accent-violet)" />
</linearGradient>
</defs>
{geometry.edges.map((edge) => {
const parent = byId.get(edge.parentId) as TreeGeometryNode;
const child = byId.get(edge.childId) as TreeGeometryNode;
const startX = parent.x + 1 + parent.recordWidth;
const midpoint = startX + (child.x - startX) / 2;
const source: LinkPoint = [startX, parent.centerY];
const target: LinkPoint = [child.x, child.centerY];
return (
<g
key={`${edge.parentId}-${edge.childId}`}
data-logic-edge-child={edge.childId}
data-logic-priority-path={
edge.isPriorityPath ? "true" : undefined
}
data-logic-related={relationTokens(edge.relatedTargetIndices)}
className={`logic-weighted-edge ${
edge.isPriorityPath ? "logic-priority-edge" : ""
}`}
fill="none"
stroke={
edge.isPriorityPath
? `url(#${gradientId})`
: "var(--color-outline)"
}
strokeLinecap="round"
strokeOpacity={edge.isPriorityPath ? 0.96 : 0.68}
strokeWidth={edge.strokeWidth}
vectorEffect="non-scaling-stroke"
>
<path
data-logic-branch-style="curved"
className="logic-curved-edge"
d={readablePath(curvedLink({ source, target }))}
vectorEffect="non-scaling-stroke"
/>
<path
data-logic-branch-style="straight"
className="logic-straight-edge"
d={readablePath(straightLink([source, target]))}
vectorEffect="non-scaling-stroke"
/>
<path
data-logic-branch-style="orthogonal"
className="logic-orthogonal-edge"
d={`M ${startX} ${parent.centerY} H ${midpoint} V ${child.centerY} H ${child.x}`}
strokeLinejoin="miter"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
{geometry.nodes.map((node) => (
<circle
key={node.id}
data-logic-node-port={node.id}
data-logic-priority-port={
node.id === geometry.priorityId ? "true" : undefined
}
data-logic-related={relationTokens(node.relatedTargetIndices)}
className={`logic-node-port ${
node.id === geometry.priorityId ? "logic-priority-port" : ""
}`}
cx={node.x}
cy={node.centerY}
r={node.id === geometry.priorityId ? 0.55 : 0.42}
fill={
node.id === geometry.priorityId
? `url(#${gradientId})`
: "var(--color-surface)"
}
stroke={
node.id === geometry.priorityId
? "var(--color-tertiary)"
: "var(--color-outline)"
}
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
{geometry.nodes.map((node) => {
const isPriority = node.id === geometry.priorityId;
const anchorPosition = {
left: `${node.x + 1}%`,
top: `${node.centerY}%`,
width: `${node.recordWidth}%`,
} as CSSProperties;
const motion = {
animationDelay: stagger(node.originalIndex),
} as CSSProperties;
return (
<div
key={node.id}
data-logic-record={node.id}
data-logic-related={relationTokens(node.relatedTargetIndices)}
data-priority={isPriority ? "true" : undefined}
className="logic-node-anchor absolute z-10 -translate-y-1/2 text-on-surface transition-[color,opacity]"
style={anchorPosition}
>
<button
type="button"
data-logic-hotspot={node.id}
data-logic-hotspot-index={node.originalIndex}
className="logic-node-record w-full animate-rise cursor-pointer py-1.5 pr-2 text-left text-inherit focus:outline-none"
style={motion}
>
<span className="block font-mono text-label text-secondary">
{nodeRole(node)}
</span>
<span className="mt-0.5 block text-label">{node.label}</span>
<span className="mt-1 flex flex-wrap items-baseline gap-x-2">
<span className="font-mono text-h3">
{formatValue(node.aggregate)}
<span className="ml-1 text-label">{unit}</span>
</span>
{isPriority && (
<span className="font-mono text-label text-tertiary">
最大寄与
</span>
)}
</span>
</button>
</div>
);
})}
</div>
);
}
function MobileTreeNode({
node,
byId,
geometry,
unit,
}: {
node: TreeGeometryNode;
byId: ReadonlyMap<string, TreeGeometryNode>;
geometry: TreeGeometry;
unit: string;
}) {
const isPriority = node.id === geometry.priorityId;
return (
<li className="relative">
<button
type="button"
data-logic-record={node.id}
data-logic-hotspot={node.id}
data-logic-hotspot-index={node.originalIndex}
data-logic-related={relationTokens(node.relatedTargetIndices)}
data-priority={isPriority ? "true" : undefined}
className="logic-node-record block w-full cursor-pointer text-left text-on-surface transition-colors focus:outline-none"
>
<span className="flex items-baseline gap-2">
<span className="font-mono text-label text-secondary">
{nodeRole(node)}
</span>
<span className="text-label">{node.label}</span>
<span className="ml-auto shrink-0 font-mono text-body-sm">
{formatValue(node.aggregate)}
<span className="ml-1 text-label">{unit}</span>
</span>
</span>
<span className="mt-2 block h-1.5 w-full overflow-hidden rounded-full bg-neutral">
<span
data-logic-mobile-rail={node.id}
data-logic-related={relationTokens(node.relatedTargetIndices)}
className={`logic-mobile-rail block h-full rounded-full transition-colors ${
isPriority
? "bg-gradient-to-r from-tertiary to-accent-violet"
: node.depth === 0
? "bg-primary"
: "bg-secondary/40"
}`}
style={{ width: `${node.share * 100}%` }}
/>
</span>
{isPriority && (
<span className="mt-1 block text-right font-mono text-label text-tertiary">
最大寄与
</span>
)}
</button>
{node.childIds.length > 0 && (
<ul className="ml-3 mt-4 space-y-4 border-l border-outline pl-5">
{node.childIds.map((childId) => {
const child = byId.get(childId) as TreeGeometryNode;
return (
<MobileTreeNode
key={childId}
node={child}
byId={byId}
geometry={geometry}
unit={unit}
/>
);
})}
</ul>
)}
</li>
);
}
function MobileTree({
geometry,
unit,
}: {
geometry: TreeGeometry;
unit: string;
}) {
const byId = new Map(geometry.nodes.map((node) => [node.id, node]));
const root = geometry.nodes.find(
(node) => node.depth === 0,
) as TreeGeometryNode;
return (
<div
data-logic-tree="mobile"
className="rounded-lg bg-surface-subtle/50 p-4 sm:hidden"
>
<p className="mb-5 text-body-sm text-secondary">
幹から枝、葉へ分解。railの長さは全体への寄与を表します。
</p>
<ul>
<MobileTreeNode
node={root}
byId={byId}
geometry={geometry}
unit={unit}
/>
</ul>
</div>
);
}
export default function LogicTree({
title,
subtitle,
unit = "",
nodes,
instanceId,
}: LogicTreeProps) {
const autoId = useId();
const uid =
(instanceId ?? autoId).replace(/[^a-zA-Z0-9_-]/g, "") || "logic-tree";
const geometry = treeGeometry(nodes);
const tree = buildTree(nodes);
const flat = flattenTree(tree);
const leaves = rankedLeaves(nodes);
const notes = flat.filter((node) => node.note);
const gradientId = `${uid}-logic-priority`;
return (
<section
data-logic-root="true"
className="group/logic mx-auto max-w-slide bg-surface p-6 font-sans text-on-surface sm:p-12"
>
<style>{`
.logic-weighted-edge {
transition: stroke 140ms ease-out, stroke-opacity 140ms ease-out;
}
.logic-node-port {
transition: fill 140ms ease-out, stroke 140ms ease-out, opacity 140ms ease-out;
}
[data-logic-tree]:has([data-logic-hotspot]:is(:hover, :focus, :active)) .logic-priority-edge {
stroke-opacity: 0.28;
}
[data-logic-tree]:has([data-logic-hotspot]:is(:hover, :focus, :active)) .logic-priority-port {
opacity: 0.28;
}
[data-logic-tree]:has([data-logic-hotspot]:is(:hover, :focus, :active)) [data-logic-record][data-priority="true"] {
opacity: 0.56;
}
.logic-straight-edge,
.logic-orthogonal-edge {
display: none;
}
[data-logic-root]:has(.logic-straight-branch-view:checked) .logic-curved-edge {
display: none;
}
[data-logic-root]:has(.logic-straight-branch-view:checked) .logic-straight-edge {
display: inline;
}
[data-logic-root]:has(.logic-orthogonal-branch-view:checked) .logic-curved-edge {
display: none;
}
[data-logic-root]:has(.logic-orthogonal-branch-view:checked) .logic-orthogonal-edge {
display: inline;
}
.logic-node-record,
.logic-node-record:focus-visible {
outline: none;
}
${interactionCss(geometry)}
`}</style>
<input
id={`${uid}-tree`}
name={`${uid}-view`}
type="radio"
defaultChecked
className="logic-tree-view sr-only"
/>
<input
id={`${uid}-ranked`}
name={`${uid}-view`}
type="radio"
className="logic-ranked-view sr-only"
/>
<input
id={`${uid}-branch-curved`}
name={`${uid}-branch-style`}
type="radio"
defaultChecked
className="logic-curved-branch-view sr-only"
/>
<input
id={`${uid}-branch-straight`}
name={`${uid}-branch-style`}
type="radio"
className="logic-straight-branch-view sr-only"
/>
<input
id={`${uid}-branch-orthogonal`}
name={`${uid}-branch-style`}
type="radio"
className="logic-orthogonal-branch-view sr-only"
/>
<header className="mb-7 flex flex-wrap items-end justify-between gap-4">
<div className="max-w-3xl">
<h1 className="text-h1">{title}</h1>
{subtitle && (
<p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
)}
</div>
<div className="flex rounded-sm border border-outline bg-surface-subtle p-1">
<label
htmlFor={`${uid}-tree`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.logic-tree-view:checked]/logic:bg-primary group-has-[.logic-tree-view:checked]/logic:text-on-primary"
>
構造
</label>
<label
htmlFor={`${uid}-ranked`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.logic-ranked-view:checked]/logic:bg-primary group-has-[.logic-ranked-view:checked]/logic:text-on-primary"
>
寄与度
</label>
</div>
</header>
<div
data-testid="logic-tree"
className="block group-has-[.logic-ranked-view:checked]/logic:hidden"
>
<div className="mb-3 hidden items-center justify-between gap-4 sm:flex">
<span className="font-mono text-label text-secondary">
ROOT → BRANCH → DRIVER
</span>
<div className="flex items-center gap-4">
<span className="font-mono text-label text-secondary">
線幅 = 寄与度
</span>
<fieldset
data-logic-branch-controls="true"
className="flex items-center gap-1 rounded-sm border border-outline bg-surface-subtle p-1"
>
<legend className="sr-only">枝の形</legend>
<span
aria-hidden="true"
className="px-2 font-mono text-label text-secondary"
>
枝の形
</span>
<label
htmlFor={`${uid}-branch-curved`}
className="flex cursor-pointer items-center gap-1.5 rounded-sm px-2.5 py-1 text-label text-secondary transition-colors group-has-[.logic-curved-branch-view:checked]/logic:bg-surface group-has-[.logic-curved-branch-view:checked]/logic:text-on-surface"
>
<svg
aria-hidden="true"
className="h-3.5 w-5"
viewBox="0 0 20 14"
fill="none"
>
<path
d="M1 12 C8 12 8 2 19 2"
stroke="currentColor"
strokeLinecap="round"
/>
</svg>
曲線
</label>
<label
htmlFor={`${uid}-branch-straight`}
className="flex cursor-pointer items-center gap-1.5 rounded-sm px-2.5 py-1 text-label text-secondary transition-colors group-has-[.logic-straight-branch-view:checked]/logic:bg-surface group-has-[.logic-straight-branch-view:checked]/logic:text-on-surface"
>
<svg
aria-hidden="true"
className="h-3.5 w-5"
viewBox="0 0 20 14"
fill="none"
>
<path
d="M1 12 L19 2"
stroke="currentColor"
strokeLinecap="round"
/>
</svg>
直線
</label>
<label
htmlFor={`${uid}-branch-orthogonal`}
className="flex cursor-pointer items-center gap-1.5 rounded-sm px-2.5 py-1 text-label text-secondary transition-colors group-has-[.logic-orthogonal-branch-view:checked]/logic:bg-surface group-has-[.logic-orthogonal-branch-view:checked]/logic:text-on-surface"
>
<svg
aria-hidden="true"
className="h-3.5 w-5"
viewBox="0 0 20 14"
fill="none"
>
<path
d="M1 12 H10 V2 H19"
stroke="currentColor"
strokeLinejoin="miter"
/>
</svg>
直角
</label>
</fieldset>
</div>
</div>
<DesktopTree geometry={geometry} unit={unit} gradientId={gradientId} />
<MobileTree geometry={geometry} unit={unit} />
<p className="mt-4 text-right text-label text-secondary">
親の値は葉から自動集計
</p>
</div>
<div
data-testid="logic-ranked"
className="hidden group-has-[.logic-ranked-view:checked]/logic:block"
>
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-h2">葉ドライバーの寄与度</h2>
<p className="font-mono text-label text-secondary">
合計 {formatValue(geometry.total)} {unit}
</p>
</div>
<div className="divide-y divide-outline border-y border-outline">
{leaves.map((leaf, index) => {
const value = leaf.value ?? 0;
const pct =
geometry.total === 0 ? 0 : (value / geometry.total) * 100;
const isPriority = leaf.id === geometry.priorityId;
return (
<article
key={leaf.id}
data-priority={isPriority ? "true" : "false"}
className="grid animate-rise grid-cols-[2.5rem_1fr_auto] items-center gap-3 py-4 sm:gap-4"
style={{ animationDelay: stagger(index) }}
>
<span className="font-mono text-label text-secondary">
{String(index + 1).padStart(2, "0")}
</span>
<div>
<p className="text-label">
{leaf.label}
{isPriority && (
<span className="ml-2 font-mono text-label text-tertiary">
最大寄与
</span>
)}
</p>
<div className="mt-2 h-1.5 rounded-full bg-neutral">
<div
className={
isPriority
? "h-full rounded-full bg-gradient-to-r from-tertiary to-accent-violet"
: "h-full rounded-full bg-primary/55"
}
style={{ width: `${pct}%` }}
/>
</div>
</div>
<p className="font-mono text-body-sm sm:text-h3">
{formatValue(value)}
<span className="ml-1 text-label">{unit}</span>
</p>
</article>
);
})}
</div>
</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((node) => (
<article
key={node.id}
data-note-item="true"
className="rounded-md bg-surface p-4"
>
<h3 className="text-label">{node.label}</h3>
<p className="mt-1 font-mono text-label text-secondary">
{node.children.length === 0
? "DRIVER"
: node.parentId
? "BRANCH"
: "ROOT"}{" "}
・ {formatValue(aggregateValue(node))}
{unit}
</p>
<p className="mt-2 text-body-sm">{node.note}</p>
</article>
))}
</div>
</details>
)}
</section>
);
}
import { z } from "zod";
export const logicNodeSchema = z
.object({
id: z.string().min(1).describe("親子関係から参照する一意なノードID"),
parentId: z
.string()
.min(1)
.optional()
.describe("親ノードID。rootだけ省略する"),
label: z.string().min(1).describe("課題、KPI、施策などのノード名"),
value: z
.number()
.nonnegative()
.optional()
.describe("葉だけが持つ寄与値。親は子の合計から導出する"),
note: z.string().optional().describe("定義、算定根拠、観察事実などの補足"),
})
.strict();
export const schema = z
.object({
title: z
.string()
.describe("スライドタイトル。最大寄与の葉を結論として書く"),
subtitle: z.string().optional().describe("分析範囲、算定期間、出典など"),
unit: z.string().optional().describe("百万円、時間、件などの表示単位"),
nodes: z
.array(logicNodeSchema)
.min(4)
.max(12)
.describe(
"rootを1件含む可変階層ノード(4〜12件、最大4階層)。葉のvalueから親の集約値と枝の太さを導出する",
),
instanceId: z.string().min(1).optional().describe("表示切替radioの識別子"),
})
.strict()
.superRefine((data, ctx) => {
const byId = new Map<string, (typeof data.nodes)[number]>();
data.nodes.forEach((node, index) => {
if (byId.has(node.id))
ctx.addIssue({
code: "custom",
message: "node.id は一意にすること",
path: ["nodes", index, "id"],
});
byId.set(node.id, node);
});
const roots = data.nodes.filter((node) => node.parentId === undefined);
if (roots.length !== 1)
ctx.addIssue({
code: "custom",
message: "parentIdのないrootは1件にすること",
path: ["nodes"],
});
data.nodes.forEach((node, index) => {
if (node.parentId && !byId.has(node.parentId))
ctx.addIssue({
code: "custom",
message: `未知のparentId: ${node.parentId}`,
path: ["nodes", index, "parentId"],
});
if (node.parentId === node.id)
ctx.addIssue({
code: "custom",
message: "自分自身を親にできない",
path: ["nodes", index, "parentId"],
});
});
const childCounts = new Map<string, number>();
data.nodes.forEach((node) => {
if (node.parentId)
childCounts.set(
node.parentId,
(childCounts.get(node.parentId) ?? 0) + 1,
);
});
data.nodes.forEach((node, index) => {
const hasChildren = (childCounts.get(node.id) ?? 0) > 0;
if (hasChildren && node.value !== undefined)
ctx.addIssue({
code: "custom",
message: "子を持つノードにvalueを重複入力しない",
path: ["nodes", index, "value"],
});
if (!hasChildren && node.value === undefined)
ctx.addIssue({
code: "custom",
message: "葉ノードにはvalueが必要",
path: ["nodes", index, "value"],
});
});
let cyclic = false;
data.nodes.forEach((node, index) => {
const seen = new Set<string>([node.id]);
let parentId = node.parentId;
let depth = 0;
while (parentId && byId.has(parentId)) {
if (seen.has(parentId)) {
cyclic = true;
break;
}
seen.add(parentId);
parentId = byId.get(parentId)?.parentId;
depth += 1;
}
if (depth > 3)
ctx.addIssue({
code: "custom",
message: "階層はrootを含め最大4段にすること",
path: ["nodes", index, "parentId"],
});
});
if (cyclic)
ctx.addIssue({
code: "custom",
message: "parentIdに循環を作れない",
path: ["nodes"],
});
});
export type LogicNode = z.infer<typeof logicNodeSchema>;
export type LogicTreeProps = z.infer<typeof schema>;
{
"title": "営業利益改善ドライバー — 調達単価の適正化が最大寄与",
"subtitle": "2026年度 改善余地試算。葉の金額を積み上げ、枝の太さへ反映",
"unit": "百万円",
"nodes": [
{ "id": "profit", "label": "営業利益改善", "note": "全ドライバー合計で年間420百万円の改善余地" },
{ "id": "revenue", "parentId": "profit", "label": "売上総利益", "note": "単価と数量の改善効果" },
{ "id": "price", "parentId": "revenue", "label": "価格改定", "value": 90, "note": "低採算契約18社の価格条件を見直し" },
{ "id": "mix", "parentId": "revenue", "label": "高付加価値比率", "value": 70, "note": "保守高度化メニューの構成比を8pt向上" },
{ "id": "variable", "parentId": "profit", "label": "変動費", "note": "調達と外注費の適正化" },
{ "id": "procurement", "parentId": "variable", "label": "調達単価", "value": 140, "note": "上位20品目の共同購買と契約集約" },
{ "id": "outsourcing", "parentId": "variable", "label": "外注構成", "value": 55, "note": "定型作業を内製センターへ移管" },
{ "id": "fixed", "parentId": "profit", "label": "固定費", "note": "業務量を減らして固定費を最適化" },
{ "id": "automation", "parentId": "fixed", "label": "定型業務自動化", "value": 65, "note": "月次集計と請求照合を自動化" }
]
}