索引に戻る

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

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

作品として見る

持ち帰る

コピーして貼るか、ファイルに保存して渡す。ネットにつながらない環境には「全部入り」を 1 つ渡せば足りる。

全部入り

logic-tree-everything.md

このパネルの中身を、URL の先にあるファイルの中身まで含めて 1 つにまとめた Markdown

依頼文

1.9 KB
「ロジックツリー(課題・KPIドライバー分解)」でHTML資料を作ってください。この型は実行できるので、版面を組み直す必要はありません — 必要なのは中身です。

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

## 渡す必要があるもの
- 親子関係を持つ要素
- 末端要素の寄与値
- 分解の起点となる課題またはKPI

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

入力の機械可読な検証は schema.ts が持ちます。足りない入力は推測せず質問してください。

## 向かない場合
- 固定3階層を重みつきの戦略帯として見せたい場合
- 時間順の工程やイベントを説明したい場合

## 作り方
入力を JSON にまとめ、次を実行します。

```bash
node scripts/render.mjs logic-tree <入力.json> --theme ink -o out.html
```

出力は 1 ファイルで完結し、外部から何も読み込みません。

## 原本
- https://design-vault.pages.dev/designs/logic-tree/source/template.tsx
- https://design-vault.pages.dev/designs/logic-tree/source/schema.ts
- https://design-vault.pages.dev/designs/logic-tree/source/sample-data.json
- https://design-vault.pages.dev/designs/logic-tree/sample.html

ファイル

テンプレート・入力スキーマ・サンプルデータ・メタ情報。この文書はこの 4 つから生成されています。 生成された文書

template.tsx

27.5 KB

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

中身を見る
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";

// 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 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={joinIds(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 min-w-0 flex-wrap items-baseline gap-2">
          <span className="shrink-0 font-mono text-label text-secondary">
            {nodeRole(node)}
          </span>
          <span className="min-w-0 flex-1 break-words text-label">{node.label}</span>
          <span className="ml-auto flex max-w-full min-w-0 flex-wrap justify-end gap-x-1 font-mono text-body-sm">
            {formatValue(node.aggregate)}
            <span className="break-all 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(
      // 捨てる操作は単射でないので、schema を通る別々の値が同じ id になる
      // (`sales:a` と `sales/a` がどちらも `salesa`)。同じ文書へ 2 つ置くと
      // id と radio の name が衝突して、片方の操作がもう片方に効く。
      // 逃がし文字(`_`)自身も逃がすので、この変換は単射。
      /[^a-zA-Z0-9-]/g,
      (c) => `_${c.charCodeAt(0).toString(16)}_`,
    ) || "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.ts

4.0 KB

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

中身を見る
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>;

meta.yaml

3.1 KB

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

中身を見る
id: logic-tree
name:
  ja: "ロジックツリー(課題・KPIドライバー分解)"
  en: "Logic Tree (Problem / KPI Driver Decomposition)"
purpose:
  ja: "親IDで結んだ可変階層をD3のtidy treeで幹・枝・葉へ配置し、葉の寄与値を親へ自動集計して細い枝の線幅へ反映する。同じnode identityを構造から葉ドライバーの寄与度へ切り替え、構造では曲線・直線・直角の枝を閲覧者が選び、最大寄与の祖先経路を1件強調する"
  en: "A hierarchy of any depth laid out as a tidy tree, with leaf contributions rolled up to their parents and carried in the thickness of each branch. The same nodes switch between structure and contribution"
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: decomposition
catalogOrder: 110
jobs:
  - explain-structure
  - prioritize-diagnose
useCases:
  - problem-solution
layoutKinds:
  - tree
tags: [logic-tree, driver-tree, decomposition, hierarchy, data-driven, interactive]
whenToUse:
  - ja: "課題・原因・施策をMECEな枝へ分解する場合"
    en: "Breaking a problem, its causes and its remedies into mutually exclusive branches"
  - ja: "利益・コスト・KPIを構成ドライバーへ展開する場合"
    en: "Expanding a profit, a cost or a KPI into the drivers behind it"
  - ja: "固定3階層ではなく、枝ごとに異なる深さを持つ構造を示す場合"
    en: "A structure whose branches differ in depth, rather than three fixed tiers"
  - ja: "構造だけでなく、どの末端要素の寄与が大きいかを説明したい場合"
    en: "Explaining not only the structure but which leaf contributes most"
  - ja: "枝ごとの集約値の大小と、最大寄与へ至る祖先経路を同じ図で追いたい場合"
    en: "Following both the size of each branch and the path to the largest contributor"
  - ja: "曲線・直線・直角のマインドマップ表現を閲覧者が切り替えて読みたい場合"
    en: "Letting the reader switch between curved, straight and right-angled branches"
avoidWhen:
  - ja: "固定3階層を重みつきの戦略帯として見せたい場合"
    en: "Presenting three fixed tiers as a weighted strategy band"
  - ja: "時間順の工程やイベントを説明したい場合"
    en: "Explaining steps or events in time order"
requiredInputs:
  - ja: "親子関係を持つ要素"
    en: "Elements with a parent-child relationship"
  - ja: "末端要素の寄与値"
    en: "A contribution value for each leaf"
  - ja: "分解の起点となる課題またはKPI"
    en: "The problem or KPI the decomposition starts from"
relatedLayouts: [pyramid, waterfall, critical-path-gantt]
dataSchema:
  title: "string — スライドタイトル。最大寄与の葉を結論として書く"
  subtitle: "string(任意)— 分析範囲、算定期間、出典など"
  unit: "string(任意)— 百万円、時間、件など"
  nodes: "{ id, parentId?, label, value?, note? }[] — 4〜12件。rootはparentIdなし、葉だけvalueを持ち、最大4階層"
sources: []

sample-data.json

1.4 KB

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

中身を見る
{
  "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": "月次集計と請求照合を自動化" }
  ]
}