BizLayout

型番 logic-tree

ロジックツリー(課題・KPIドライバー分解)

親IDで結んだ可変階層をD3のtidy treeで幹・枝・葉へ配置し、葉の寄与値を親へ自動集計して細い枝の線幅へ反映する。同じnode identityを構造から葉ドライバーの寄与度へ切り替え、構造では曲線・直線・直角の枝を閲覧者が選び、最大寄与の祖先経路を1件強調する

logic-tree · driver-tree · decomposition · hierarchy · data-driven · interactive

LIVE SAMPLE

プレビュー

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

営業利益改善ドライバー — 調達単価の適正化が最大寄与

2026年度 改善余地試算。葉の金額を積み上げ、枝の太さへ反映

幹から枝、葉へ分解。railの長さは全体への寄与を表します。

親の値は葉から自動集計

補足を確認9

営業利益改善

ROOT 420百万円

全ドライバー合計で年間420百万円の改善余地

売上総利益

BRANCH 160百万円

単価と数量の改善効果

価格改定

DRIVER 90百万円

低採算契約18社の価格条件を見直し

高付加価値比率

DRIVER 70百万円

保守高度化メニューの構成比を8pt向上

変動費

BRANCH 195百万円

調達と外注費の適正化

調達単価

DRIVER 140百万円

上位20品目の共同購買と契約集約

外注構成

DRIVER 55百万円

定型作業を内製センターへ移管

固定費

BRANCH 65百万円

業務量を減らして固定費を最適化

定型業務自動化

DRIVER 65百万円

月次集計と請求照合を自動化

こういうときに使う

  • 課題・原因・施策をMECEな枝へ分解する場合
  • 利益・コスト・KPIを構成ドライバーへ展開する場合
  • 固定3階層ではなく、枝ごとに異なる深さを持つ構造を示す場合
  • 構造だけでなく、どの末端要素の寄与が大きいかを説明したい場合
  • 枝ごとの集約値の大小と、最大寄与へ至る祖先経路を同じ図で追いたい場合
  • 曲線・直線・直角のマインドマップ表現を閲覧者が切り替えて読みたい場合

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。最大寄与の葉を結論として書く
subtitle
string(任意)— 分析範囲、算定期間、出典など
unit
string(任意)— 百万円、時間、件など
nodes
{ id, parentId?, label, value?, note? }[] — 4〜12件。rootはparentIdなし、葉だけvalueを持ち、最大4階層

サンプルコード

template.tsx783 行
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>
  );
}
schema.ts127 行
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>;
sample-data.json16 行
{
  "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": "月次集計と請求照合を自動化" }
  ]
}