Back to index

Strategy Map (Cause and Effect Across Perspectives)

Network
Generates documents
Strategy Map (Cause and Effect Across Perspectives)

Seen as a design

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

strategy-map-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

1.4 KB
Build an HTML document with "Strategy Map (Cause and Effect Across Perspectives)". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
Objectives by perspective joined by causal edges, with the ones to focus on following from influence and current level

## What you have to supply
- The strategic perspectives
- The objectives under each
- The causal links between them, and how they are measured

## The shape of the input
- `perspectives` — { id, name }[] — 3〜5視点
- `objectives` — { id, label, perspectiveId, impact, current, note? }[] — 4〜12目標
- `links` — { sourceId, targetId, strength }[] — 3〜16因果

schema.ts holds the machine-checkable validation. Ask rather than guess when an input is missing.

## When not to use it
- Showing a split across three fixed tiers
- Decomposing a variable hierarchy into parent and child

## How to build it
Put the input in a JSON file and run:

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

The output is one self-contained file and fetches nothing at render time.

## The source
- https://design-vault.pages.dev/designs/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

Files

Template, input schema, sample data and metadata. This document was generated from those four. The generated document

template.tsx

13.1 KB

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

Show contents
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

Show contents
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

Show contents
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

Show contents
{
  "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"
}