索引に戻る

ストラテジーマップ(視点横断因果)

ネットワーク
生成可能
ストラテジーマップ(視点横断因果)

作品として見る

持ち帰る

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

全部入り

strategy-map-everything.md

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

依頼文

1.4 KB
「ストラテジーマップ(視点横断因果)」でHTML資料を作ってください。この型は実行できるので、版面を組み直す必要はありません — 必要なのは中身です。

## この型がすること
視点別の戦略目標を因果edgeで結び、影響度と現在水準から重点目標を導出する

## 渡す必要があるもの
- 戦略視点
- 視点ごとの目標
- 目標間の因果関係と評価尺度

## 入力の形
- `perspectives` — { id, name }[] — 3〜5視点
- `objectives` — { id, label, perspectiveId, impact, current, note? }[] — 4〜12目標
- `links` — { sourceId, targetId, strength }[] — 3〜16因果

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

## 向かない場合
- 固定3階層の配分を示したい場合
- 可変階層を親子へ分解したい場合

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

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

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

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

ファイル

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

template.tsx

13.1 KB

/designs/strategy-map/source/template.tsx

中身を見る
import { useId } from "react";
import type { StrategyMapProps, StrategyObjective } from "./schema";

type StrategyPerspective = StrategyMapProps["perspectives"][number];
type StrategyLink = StrategyMapProps["links"][number];

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

export function rankStrategyGaps(objectives: StrategyObjective[]) {
  return objectives
    .map((objective, index) => ({
      ...objective,
      gap: objective.impact * (6 - objective.current),
      inputIndex: index,
    }))
    .sort((a, b) => b.gap - a.gap || a.inputIndex - b.inputIndex);
}

export function strategyGeometry(
  perspectives: StrategyPerspective[],
  objectives: StrategyObjective[],
  links: StrategyLink[],
) {
  const nodes = objectives.map((objective) => {
    const perspectiveIndex = perspectives.findIndex(
      (perspective) => perspective.id === objective.perspectiveId,
    );
    const peers = objectives.filter(
      (item) => item.perspectiveId === objective.perspectiveId,
    );
    const peerIndex = peers.findIndex((item) => item.id === objective.id);
    const x = 22 + ((peerIndex + 1) / (peers.length + 1)) * 56;
    const y = perspectives.length === 1
      ? 50
      : 82 - (perspectiveIndex / (perspectives.length - 1)) * 64;
    const radius = 2.8 + objective.impact * 0.65;
    const gapRadius = radius + 2 + (6 - objective.current) * 0.45;
    return {
      ...objective,
      x: round(x),
      y: round(y),
      radius: round(radius),
      gapRadius: round(gapRadius),
      gap: objective.impact * (6 - objective.current),
    };
  });
  const position = new Map(nodes.map((node) => [node.id, node]));
  const edges = links.map((link, index) => ({
    ...link,
    id: `${link.sourceId}-${link.targetId}-${index}`,
    source: position.get(link.sourceId)!,
    target: position.get(link.targetId)!,
    strokeWidth: round(1 + link.strength * 0.65),
  }));
  return { nodes, edges };
}

export default function StrategyMap({
  title,
  subtitle,
  perspectives,
  objectives,
  links,
  instanceId,
}: StrategyMapProps) {
  // 既定を固定文字列にすると、id を指定せず 2 つ置いたときに radio の name と
  // SVG marker の id を共有してしまう。他の型と同じく useId() に寄せる。
  const autoId = useId();
  const uid = (instanceId ?? autoId).replace(
      // 逃がし文字(`_`)自身も逃がすので単射。捨てる形にすると `a:b` と `a/b` が
      // 同じ id になる。
      /[^a-zA-Z0-9-]/g,
      (c: string) => `_${c.charCodeAt(0).toString(16)}_`,
    ) || "strategy-map";
  const ranked = rankStrategyGaps(objectives);
  const priority = ranked[0];
  const notes = objectives.filter((item) => item.note);
  const geometry = strategyGeometry(perspectives, objectives, links);
  const markerId = `${uid}-causal-arrow`;
  const canvasX = (value: number) => value * 10;
  const canvasY = (value: number) => value * 4.2;

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

      <div className="mt-5 flex flex-wrap justify-between gap-3">
        <div className="inline-flex rounded-full border border-outline bg-surface-subtle p-1 text-label">
          <label className="rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="strategy-view-map sr-only" type="radio" name={`${uid}-view`} defaultChecked />因果
          </label>
          <label className="rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="strategy-view-gap sr-only" type="radio" name={`${uid}-view`} />重点
          </label>
        </div>
        <span className="font-mono text-label text-secondary">RING=GAP · LINE=STRENGTH</span>
      </div>

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

      <div data-strategy-view="map" className="mt-6 group-has-[.strategy-view-gap:checked]/strategy:hidden">
        <svg
          className="hidden h-[26rem] w-full sm:block"
          viewBox="0 0 1000 420"
          role="img"
          aria-label="下位の基盤から上位の成果へ向かう戦略因果マップ"
          data-primary-geometry="strategy-map"
          data-geometry-channels="perspective position impact gap link-strength"
        >
          <defs>
            <marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
              <path d="M 0 0 L 10 5 L 0 10 z" className="fill-primary" />
            </marker>
          </defs>

          {perspectives.map((perspective, index) => {
            const yPercent = perspectives.length === 1 ? 50 : 82 - (index / (perspectives.length - 1)) * 64;
            const y = canvasY(yPercent);
            return (
              <g key={perspective.id}>
                <line x1="112" x2="972" y1={y} y2={y} className="stroke-outline" vectorEffect="non-scaling-stroke" />
                <text x="12" y={y + 5} className="fill-secondary font-mono text-label">P{String(index + 1).padStart(2, "0")}</text>
              </g>
            );
          })}

          {geometry.edges.map((edge) => {
            const sourceX = canvasX(edge.source.x);
            const sourceY = canvasY(edge.source.y);
            const targetX = canvasX(edge.target.x);
            const targetY = canvasY(edge.target.y);
            const deltaX = targetX - sourceX;
            const deltaY = targetY - sourceY;
            const distance = Math.hypot(deltaX, deltaY) || 1;
            const unitX = deltaX / distance;
            const unitY = deltaY / distance;
            const sourceClearance = edge.source.gapRadius * 2.8 + 3;
            const targetClearance = edge.target.gapRadius * 2.8 + 9;
            const startX = sourceX + unitX * sourceClearance;
            const startY = sourceY + unitY * sourceClearance;
            const endX = targetX - unitX * targetClearance;
            const endY = targetY - unitY * targetClearance;
            const middleY = (startY + endY) / 2;
            const touchesPriority = edge.sourceId === priority.id || edge.targetId === priority.id;
            return (
              <path
                key={edge.id}
                d={`M ${startX} ${startY} C ${startX} ${middleY}, ${endX} ${middleY}, ${endX} ${endY}`}
                fill="none"
                vectorEffect="non-scaling-stroke"
                strokeWidth={edge.strokeWidth}
                markerEnd={`url(#${markerId})`}
                className={touchesPriority ? "stroke-tertiary" : "stroke-primary"}
                data-primary-edge={edge.id}
              />
            );
          })}

          {geometry.nodes.map((node, index) => {
            const x = canvasX(node.x);
            const y = canvasY(node.y);
            const radius = node.radius * 2.8;
            const gapRadius = node.gapRadius * 2.8;
            const circumference = 2 * Math.PI * gapRadius;
            const currentShare = node.current / 5;
            const isPriority = node.id === priority.id;
            const labelOnLeft = node.x < 50;
            const labelX = labelOnLeft ? x - gapRadius - 10 : x + gapRadius + 10;
            const anchor = labelOnLeft ? "end" : "start";
            return (
              <g
                key={node.id}
                tabIndex={0}
                data-strategy-objective={node.id}
                data-primary-record={node.id}
                data-geometry-a={node.x}
                data-geometry-b={node.y}
                data-primary-emphasis={isPriority ? "true" : undefined}
              >
                <title>{`${node.label} · 影響${node.impact} · 現状${node.current} · GAP ${node.gap}`}</title>
                {isPriority && <circle cx={x} cy={y} r={gapRadius + 8} className="fill-tertiary/15" />}
                <circle cx={x} cy={y} r={gapRadius} fill="none" className={isPriority ? "stroke-tertiary" : "stroke-outline"} strokeWidth="2" />
                <circle
                  cx={x}
                  cy={y}
                  r={gapRadius}
                  fill="none"
                  className={isPriority ? "stroke-tertiary" : "stroke-primary"}
                  strokeWidth="3"
                  strokeDasharray={`${round(circumference * currentShare)} ${round(circumference * (1 - currentShare))}`}
                  transform={`rotate(-90 ${x} ${y})`}
                />
                <circle cx={x} cy={y} r={radius} className={isPriority ? "fill-tertiary" : "fill-primary"} />
                <text data-primary-label={node.id} x={labelX} y={y - 4} textAnchor={anchor} className="fill-primary text-label font-semibold">
                  O{String(index + 1).padStart(2, "0")}
                </text>
              </g>
            );
          })}
        </svg>

        <div className="relative space-y-5 pl-7 sm:hidden">
          <span className="absolute bottom-2 left-2 top-2 w-px bg-outline" aria-hidden="true" />
          {perspectives.map((perspective, perspectiveIndex) => (
            <section key={perspective.id} className="relative">
              <h2 className="font-mono text-label text-secondary">{String(perspectiveIndex + 1).padStart(2, "0")} · {perspective.name}</h2>
              <div className="mt-2 space-y-3">
                {objectives.filter((item) => item.perspectiveId === perspective.id).map((objective) => {
                  const score = ranked.find((item) => item.id === objective.id)!;
                  const isPriority = objective.id === priority.id;
                  return (
                    <div key={objective.id} data-strategy-objective={objective.id} data-strategy-mobile-objective={objective.id} className="relative grid grid-cols-[1fr_auto] gap-3 py-1 pl-3">
                      <span className={`absolute -left-[1.65rem] top-2 h-3 w-3 rounded-full ${isPriority ? "bg-tertiary ring-4 ring-tertiary/20" : "bg-primary"}`} aria-hidden="true" />
                      <strong>{objective.label}</strong>
                      <span className="font-mono text-label">I{objective.impact} · G{score.gap}</span>
                    </div>
                  );
                })}
              </div>
            </section>
          ))}
        </div>
      </div>

      <div className="strategy-map-key mt-5 hidden gap-x-6 gap-y-2 border-t border-outline pt-4 sm:grid sm:grid-cols-2 xl:grid-cols-3" aria-label="目標一覧">
        {objectives.map((objective, index) => {
          const perspective = perspectives.find((item) => item.id === objective.perspectiveId)!;
          const score = ranked.find((item) => item.id === objective.id)!;
          return <div key={objective.id} data-strategy-key={objective.id} className="grid grid-cols-[3rem_1fr] gap-2 py-1">
            <strong className="font-mono text-label text-tertiary">O{String(index + 1).padStart(2, "0")}</strong>
            <span>{objective.label}<span className="block text-label text-secondary">P{String(perspectives.findIndex((item) => item.id === objective.perspectiveId) + 1).padStart(2, "0")} · {perspective.name} · I{objective.impact} · C{objective.current} · GAP {score.gap}</span></span>
          </div>;
        })}
      </div>

      <div data-strategy-view="gap-ledger" className="mt-6 hidden group-has-[.strategy-view-gap:checked]/strategy:block">
        <div className="divide-y divide-outline border-y border-outline">
          {ranked.map((objective, index) => (
            <article key={objective.id} data-strategy-objective={objective.id} className={`grid grid-cols-[3rem_1fr_auto] items-center gap-3 py-3 ${index === 0 ? "border-l-4 border-tertiary pl-3" : "pl-4"}`}>
              <span className="font-mono text-label text-secondary">{String(index + 1).padStart(2, "0")}</span>
              <div><strong>{objective.label}</strong><p className="text-label text-secondary">影響{objective.impact} × 現在水準不足{6 - objective.current}</p></div>
              <span className="font-mono text-h3">{objective.gap}</span>
            </article>
          ))}
        </div>
      </div>

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

schema.ts

2.2 KB

/designs/strategy-map/source/schema.ts

中身を見る
import { z } from "zod";
const perspectiveSchema = z.object({ id: z.string().min(1), name: z.string().min(1) }).strict();
export const strategyObjectiveSchema = z.object({ id: z.string().min(1), label: z.string().min(1), perspectiveId: z.string().min(1), impact: z.number().int().min(1).max(5), current: z.number().int().min(1).max(5), note: z.string().optional() }).strict();
const strategyLinkSchema = z.object({ sourceId: z.string().min(1), targetId: z.string().min(1), strength: z.number().int().min(1).max(5) }).strict();
export const schema = z.object({ title: z.string().min(1), subtitle: z.string().optional(), perspectives: z.array(perspectiveSchema).min(3).max(5), objectives: z.array(strategyObjectiveSchema).min(4).max(12), links: z.array(strategyLinkSchema).min(3).max(16), instanceId: z.string().optional() }).strict().superRefine((value, ctx) => {
  // id は identity として使う(React の key、data 属性、対応強調の突き合わせ)。
  // 重複すると別物どうしが同じ要素を指し、操作が意図しない相手に効く。
  {
    const ids = value.perspectives.map((item) => item.id);
    if (new Set(ids).size !== ids.length) {
      ctx.addIssue({ code: "custom", path: ["perspectives"], message: "perspectives ids must be unique" });
    }
  }
  {
    const ids = value.objectives.map((item) => item.id);
    if (new Set(ids).size !== ids.length) {
      ctx.addIssue({ code: "custom", path: ["objectives"], message: "objectives ids must be unique" });
    }
  }
  const perspectives = new Set(value.perspectives.map((item) => item.id)); const ids = new Set(value.objectives.map((item) => item.id));
  value.objectives.forEach((item, index) => { if (!perspectives.has(item.perspectiveId)) ctx.addIssue({ code: "custom", path: ["objectives", index, "perspectiveId"], message: "unknown perspective" }); });
  value.links.forEach((item, index) => { if (!ids.has(item.sourceId) || !ids.has(item.targetId) || item.sourceId === item.targetId) ctx.addIssue({ code: "custom", path: ["links", index], message: "invalid objective link" }); });
});
export type StrategyObjective = z.infer<typeof strategyObjectiveSchema>;
export type StrategyMapProps = z.infer<typeof schema>;

meta.yaml

1.8 KB

/designs/strategy-map/source/meta.yaml

中身を見る
id: strategy-map
name:
  ja: "ストラテジーマップ(視点横断因果)"
  en: "Strategy Map (Cause and Effect Across Perspectives)"
purpose:
  ja: "視点別の戦略目標を因果edgeで結び、影響度と現在水準から重点目標を導出する"
  en: "Objectives by perspective joined by causal edges, with the ones to focus on following from influence and current level"
# A5 の裁定で draft として移設し、2026-09-04 に型ごとの承認を取って公開した。
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: strategy
catalogOrder: 190
jobs:
  - explain-structure
  - prioritize-diagnose
useCases:
  - summary
layoutKinds:
  - network
tags: [strategy-map, cause-effect, objective, balanced-scorecard]
whenToUse:
  - ja: "先行目標が成果へどうつながるか説明したい場合"
    en: "Explaining how a leading objective reaches an outcome"
  - ja: "戦略目標の重点化を合意したい場合"
    en: "Agreeing which strategic objectives take priority"
avoidWhen:
  - ja: "固定3階層の配分を示したい場合"
    en: "Showing a split across three fixed tiers"
  - ja: "可変階層を親子へ分解したい場合"
    en: "Decomposing a variable hierarchy into parent and child"
requiredInputs:
  - ja: "戦略視点"
    en: "The strategic perspectives"
  - ja: "視点ごとの目標"
    en: "The objectives under each"
  - ja: "目標間の因果関係と評価尺度"
    en: "The causal links between them, and how they are measured"
relatedLayouts: [logic-tree, pyramid, business-capability-map]
dataSchema:
  perspectives: "{ id, name }[] — 3〜5視点"
  objectives: "{ id, label, perspectiveId, impact, current, note? }[] — 4〜12目標"
  links: "{ sourceId, targetId, strength }[] — 3〜16因果"
sources: []

sample-data.json

1.5 KB

/designs/strategy-map/source/sample-data.json

中身を見る
{
  "title": "企画リードタイム短縮を重点化し、継続利用の成長へつなげる",
  "subtitle": "デジタル事業の戦略仮説(影響度・現在水準は5段階)",
  "perspectives": [{"id":"people","name":"人材・基盤"},{"id":"process","name":"業務プロセス"},{"id":"customer","name":"顧客"},{"id":"finance","name":"事業成果"}],
  "objectives": [
    {"id":"skills","label":"データ活用力を高める","perspectiveId":"people","impact":4,"current":3},
    {"id":"platform","label":"共通データ基盤を整える","perspectiveId":"people","impact":5,"current":2},
    {"id":"speed","label":"企画リードタイムを短縮","perspectiveId":"process","impact":5,"current":1,"note":"仮説検証に必要なデータ準備と承認が分断されている想定"},
    {"id":"quality","label":"判断品質を標準化","perspectiveId":"process","impact":4,"current":2},
    {"id":"experience","label":"継続利用を増やす","perspectiveId":"customer","impact":5,"current":3},
    {"id":"growth","label":"継続売上を伸ばす","perspectiveId":"finance","impact":5,"current":3}
  ],
  "links": [
    {"sourceId":"skills","targetId":"quality","strength":4},
    {"sourceId":"platform","targetId":"speed","strength":5},
    {"sourceId":"speed","targetId":"experience","strength":5},
    {"sourceId":"quality","targetId":"experience","strength":3},
    {"sourceId":"experience","targetId":"growth","strength":5}
  ],
  "instanceId": "digital-growth-strategy"
}