索引に戻る

ステークホルダー・ネットワーク(合意形成リスク)

ネットワーク
生成可能
ステークホルダー・ネットワーク(合意形成リスク)

作品として見る

持ち帰る

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

全部入り

stakeholder-network-everything.md

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

依頼文

1.5 KB
「ステークホルダー・ネットワーク(合意形成リスク)」でHTML資料を作ってください。この型は実行できるので、版面を組み直す必要はありません — 必要なのは中身です。

## この型がすること
支持度・関心度・影響力と関係線を一つのnetworkへ写し、最大の合意形成リスクを導出する

## 渡す必要があるもの
- 利害関係者と役割
- 影響力・関心度・支持度
- 関係者間の影響関係

## 入力の形
- `stakeholders` — { id, name, role, influence, interest, support, note? }[] — 4〜10件
- `relations` — { sourceId, targetId, strength, label? }[] — 1〜16関係

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

## 向かない場合
- 責任記号を活動別に監査したい場合
- 2軸だけで関係者を位置づけたい場合

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

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

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

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

ファイル

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

template.tsx

21.5 KB

/designs/stakeholder-network/source/template.tsx

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

type Relation = StakeholderNetworkProps["relations"][number];

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

export function rankStakeholderRisks(stakeholders: Stakeholder[]) {
  return stakeholders
    .map((item, index) => ({
      ...item,
      risk: item.influence * item.interest * (6 - item.support),
      inputIndex: index,
    }))
    .sort((a, b) => b.risk - a.risk || a.inputIndex - b.inputIndex);
}


/**
 * 図の中に置くラベルの縁取り。`stroke-surface` と組で使う。
 *
 * 塗りの前に地の色で縁を引くので、線はラベルの手前で途切れて見え、線の上にラベルが来ても
 * 字が残る。背後に何も無いところでは地と同じ色なので見えない。線を避けて置き場を解き直す
 * 道は取らない —— 密な図では解が無い入力が必ず残るが、縁取りは入力に依らず効く。
 */
const HALO = { paintOrder: "stroke", strokeWidth: 4, strokeLinejoin: "round" } as const;

export function stakeholderGeometry(stakeholders: Stakeholder[]) {
  return stakeholders.map((item) => {
    const peers = stakeholders.filter((peer) => peer.support === item.support);
    const peerIndex = peers.findIndex((peer) => peer.id === item.id);
    const peerSpread = (peerIndex - (peers.length - 1) / 2) * 18;
    const degrees = 200 - ((item.support - 1) / 4) * 220 + peerSpread;
    const angle = (degrees * Math.PI) / 180;
    const distance = 60 + item.interest * 24;
    return {
      ...item,
      x: round(500 + Math.cos(angle) * distance),
      y: round(200 + Math.sin(angle) * distance),
      radius: 3 + item.influence,
      degrees: round(degrees),
      distance,
    };
  });
}

/**
 * 描く前に文字の幅を見積もる。全角は 1em、半角は 0.58em(timeline と同じ見積もり)。
 * 置き場を決めるのが描画より前なので、実測ではなく見積もりで決める。
 */
const estimateTextWidth = (text: string, fontPx: number): number => {
  let em = 0;
  for (const ch of text) em += (ch.codePointAt(0) ?? 0) >= 0x1100 ? 1 : 0.58;
  return em * fontPx;
};

/** 二次ベジエ `M p0 Q p1 p2` 上の点 */
const bezierPoint = (
  p0: { x: number; y: number },
  p1: { x: number; y: number },
  p2: { x: number; y: number },
  t: number,
) => ({
  x: (1 - t) ** 2 * p0.x + 2 * (1 - t) * t * p1.x + t ** 2 * p2.x,
  y: (1 - t) ** 2 * p0.y + 2 * (1 - t) * t * p1.y + t ** 2 * p2.y,
});

/** ラベルは点の 13 上に baseline を置く。矩形は baseline から上へ 12・下へ 3 */
const LABEL_BASELINE = 13;
const LABEL_TOP = LABEL_BASELINE + 12;
const LABEL_BOTTOM = LABEL_BASELINE - 3;
/** 丸との間に空ける距離。この型が丸とラベルの間に使っている値 */
const LABEL_CLEARANCE = 4;
/** 矩形の中心から baseline までの距離(下向きが正) */
const BASELINE_FROM_CENTER = (LABEL_TOP + LABEL_BOTTOM) / 2 - LABEL_BASELINE;

/**
 * 関係線のラベルを、線に沿ったどこへ置くか。
 *
 * 中点に固定すると、関係者を極座標に並べるこの型では**長い線の中点が無関係な
 * 第三の丸の上に乗る**。実測(1280px)で「実行報告」が事業責任者の丸へ 21.4px
 * 入り、ほとんど読めなかった。線の両端を避けるだけでは足りない —— 邪魔をするのは
 * その線が結んでいない丸のほうだった。
 *
 * 線に沿った位置(t)と、線に対して直角の逃がし量の組を、決め打ちの順で試して
 * 最初に空いたものを採る。中点・逃がし 0 から見るので、空いている図では従来どおり
 * 中点に置かれる。
 *
 * 2 つの形を先に捨てている。
 *
 * 「いちばん食い込んでいる丸から押し出す」反復は、近い 2 つの丸の間で往復して
 * 重なったまま返る(総当たり 116000 通りで 1080 件・最悪 -18.9px)。
 *
 * 有限の候補リストから選んで、どれも空かなければ「いちばんまし」を返す形も足りない。
 * リストの外に答えがある入力が必ず残る(無作為 276682 本で 150 件・最悪 -5.4px)。
 * どちらも外部レビューが指摘して、総当たりで裏を取った。
 *
 * だから最後の一手は探索ではなく計算にする。どの丸からも離れていることが式で
 * 分かる距離まで逃がせば、候補が尽きても後条件は必ず満たされる。近い順に試すので、
 * 空いている図では従来どおり中点に置かれ、この最後の一手は使われない。
 */
export const relationLabelPoint = (
  start: { x: number; y: number },
  control: { x: number; y: number },
  end: { x: number; y: number },
  label: string,
  obstacles: { x: number; y: number; radius: number }[],
) => {
  const halfWidth = estimateTextWidth(label, 13) / 2;
  const halfHeight = (LABEL_TOP - LABEL_BOTTOM) / 2;
  /** 矩形の中心から見て、いちばん食い込んでいる丸との余白 */
  const clearanceAt = (cx: number, cy: number) =>
    obstacles.reduce((least, o) => {
      const nx = Math.max(cx - halfWidth, Math.min(o.x, cx + halfWidth));
      const ny = Math.max(cy - halfHeight, Math.min(o.y, cy + halfHeight));
      // Math.hypot は仕様上「実装依存の近似」で、Math.sqrt だけが IEEE-754 の
      // 正しい丸めを要求される。ここの値は「どこに置くか」の分岐に入るので、
      // 最下位ビットの差が位置の差になって機械ごとに別の絵が出る
      // (5225ecc が sankey-flow で踏んだのと同じ種類の食い違い)。
      return Math.min(least, Math.sqrt((nx - o.x) ** 2 + (ny - o.y) ** 2) - o.radius);
    }, Number.POSITIVE_INFINITY);

  const T_CANDIDATES = [0.5, 0.44, 0.56, 0.38, 0.62, 0.32, 0.68, 0.26, 0.74, 0.2, 0.8, 0.14, 0.86];
  const OFFSET_STEP = 16;
  const OFFSET_STEPS = 8;

  /** t での接線から、線に直角な単位ベクトル */
  const normalAt = (t: number) => {
    const tangentX = 2 * (1 - t) * (control.x - start.x) + 2 * t * (end.x - control.x);
    const tangentY = 2 * (1 - t) * (control.y - start.y) + 2 * t * (end.y - control.y);
    const length = Math.sqrt(tangentX ** 2 + tangentY ** 2) || 1;
    return { x: -tangentY / length, y: tangentX / length };
  };
  /** 返す値そのもので測る —— 丸めた後に条件が崩れていないことを見る */
  const place = (point: { x: number; y: number }, normal: { x: number; y: number }, offset: number) => {
    const x = round(point.x + normal.x * offset);
    const y = round(point.y + normal.y * offset - LABEL_BASELINE);
    return { x, y, clearance: clearanceAt(x, y - BASELINE_FROM_CENTER) };
  };

  for (let step = 0; step <= OFFSET_STEPS; step += 1) {
    for (const t of T_CANDIDATES) {
      const point = bezierPoint(start, control, end, t);
      const normal = normalAt(t);
      // 逃がし 0 は 1 度だけ。近いほうから見るので、空いていれば中点に落ち着く
      for (const offset of step === 0 ? [0] : [step * OFFSET_STEP, -step * OFFSET_STEP]) {
        const placed = place(point, normal, offset);
        if (placed.clearance >= LABEL_CLEARANCE) return { x: placed.x, y: placed.y };
      }
    }
  }

  // 候補が尽きた。中点から、どの丸からも離れることが式で分かる距離まで逃がす。
  //
  // 矩形の中心が丸の中心から「丸の半径 + 矩形の対角の半分 + 余白」より遠ければ、
  // 矩形上のどの点も丸の外にある。全部の丸についてその最大を取る。
  //
  // 矩形の中心は逃がした点そのものではなく、baseline 変換のぶん
  // (LABEL_BASELINE + BASELINE_FROM_CENTER) だけ上にある。この分を式に入れ忘れると
  // 距離が最大 17.5px 足りない(外部レビューが 10 人の配置で 1.85px しか空かない
  // 例を出した)。式で出した距離も丸めるので、最後に必ず測ってから返す。
  const point = bezierPoint(start, control, end, 0.5);
  const normal = normalAt(0.5);
  const reach = obstacles.reduce((far, o) => {
    const toObstacle = Math.sqrt((o.x - point.x) ** 2 + (o.y - point.y) ** 2);
    return Math.max(far, toObstacle + o.radius);
  }, 0);
  let offset =
    reach +
    Math.sqrt(halfWidth ** 2 + halfHeight ** 2) +
    LABEL_BASELINE +
    BASELINE_FROM_CENTER +
    LABEL_CLEARANCE +
    1; // 丸めのぶん
  let safe = place(point, normal, offset);
  // 式は上限のはずだが、それを信じずに測る。足りなければ外へ伸ばす —— 外へ動かす
  // ほど余白は増えるので、この繰り返しは往復しない。
  for (let i = 0; i < 8 && safe.clearance < LABEL_CLEARANCE; i += 1) {
    offset += LABEL_CLEARANCE - safe.clearance + 1;
    safe = place(point, normal, offset);
  }
  return { x: safe.x, y: safe.y };
};

export function stakeholderNetworkGeometry(
  stakeholders: Stakeholder[],
  relations: Relation[],
) {
  const nodes = stakeholderGeometry(stakeholders);
  const positions = new Map(nodes.map((node) => [node.id, node]));
  const edges = relations.map((relation, index) => ({
    ...relation,
    id: `${relation.sourceId}-${relation.targetId}-${index}`,
    source: positions.get(relation.sourceId)!,
    target: positions.get(relation.targetId)!,
    strokeWidth: round(1 + relation.strength * 0.65),
  }));
  return { nodes, edges };
}

export default function StakeholderNetwork({
  title,
  subtitle,
  stakeholders,
  relations,
  instanceId,
}: StakeholderNetworkProps) {
  // 既定を固定文字列にすると、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)}_`,
    ) || "stakeholder-network";
  const ranked = rankStakeholderRisks(stakeholders);
  const priority = ranked[0];
  const geometry = stakeholderNetworkGeometry(stakeholders, relations);
  const notes = stakeholders.filter((item) => item.note);
  const markerId = `${uid}-influence-arrow`;
  const drawnEdges = geometry.edges.map((edge) => {
    const dx = edge.target.x - edge.source.x;
    const dy = edge.target.y - edge.source.y;
    // この値は startX/startY を通ってラベルの置き場の分岐に入るので、実装依存の近似である
    // Math.hypot ではなく、正しい丸めが保証される Math.sqrt を使う。
    const distance = Math.sqrt(dx ** 2 + dy ** 2) || 1;
    const ux = dx / distance;
    const uy = dy / distance;
    const sourceRadius = edge.source.radius * 3;
    const targetRadius = edge.target.radius * 3;
    const startX = edge.source.x + ux * (sourceRadius + 4);
    const startY = edge.source.y + uy * (sourceRadius + 4);
    const endX = edge.target.x - ux * (targetRadius + 10);
    const endY = edge.target.y - uy * (targetRadius + 10);
    const midX = (startX + endX) / 2;
    const midY = (startY + endY) / 2;
    return {
      edge,
      path: `M ${startX} ${startY} Q ${midX} ${midY - 16} ${endX} ${endY}`,
      spot: edge.label
        ? relationLabelPoint(
            { x: startX, y: startY },
            { x: midX, y: midY - 16 },
            { x: endX, y: endY },
            edge.label,
            geometry.nodes.map((n) => ({ x: n.x, y: n.y, radius: n.radius * 3 })),
          )
        : null,
      touchesPriority:
        edge.sourceId === priority.id || edge.targetId === priority.id,
    };
  });

  return (
    <section
      className="asset group/stakeholder overflow-hidden rounded-xl border border-outline bg-surface p-5 text-primary shadow-card sm:p-8"
      data-layout="stakeholder-network"
    >
      <header className="border-b border-outline pb-5">
        <p className="font-mono text-label text-tertiary">INFLUENCE ORBIT</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="stakeholder-view-network 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="stakeholder-view-risk sr-only" type="radio" name={`${uid}-view`} />合意リスク
          </label>
        </div>
        <span className="font-mono text-label text-secondary">ANGLE=SUPPORT · ORBIT=INTEREST · SIZE=INFLUENCE</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-stakeholder-default-priority="true"
      >
        <span className="font-mono text-label text-tertiary">ALIGN FIRST</span>
        <strong>{priority.name}</strong>
        <span className="font-mono text-h3">RISK {priority.risk}</span>
      </div>

      <div data-stakeholder-view="network" className="mt-6 group-has-[.stakeholder-view-risk:checked]/stakeholder:hidden">
        <svg
          className="hidden h-96 w-full sm:block"
          viewBox="0 0 1000 440"
          role="img"
          aria-label="支持度の角度、関心度の距離、影響度の大きさで示す関係者ネットワーク"
          data-primary-geometry="stakeholder-network"
          data-geometry-channels="support-angle interest-orbit influence-radius relation-width"
        >
          <defs>
            <marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto">
              <path d="M 0 0 L 10 5 L 0 10 z" className="fill-primary" />
            </marker>
          </defs>

          {[84, 132, 180].map((radius, index) => (
            <path
              key={radius}
              d={`M ${500 - radius} 200 A ${radius} ${radius} 0 1 1 ${500 + radius} 200`}
              fill="none"
              className={index === 2 ? "stroke-outline" : "stroke-outline/70"}
              strokeDasharray={index === 2 ? undefined : "5 7"}
              vectorEffect="non-scaling-stroke"
            />
          ))}
          <line x1="300" x2="700" y1="200" y2="200" className="stroke-outline" vectorEffect="non-scaling-stroke" />
          <line x1="500" x2="500" y1="200" y2="400" className="stroke-outline" strokeDasharray="5 7" vectorEffect="non-scaling-stroke" />
          <text x="292" y="196" textAnchor="end" className="fill-secondary font-mono text-label">OPPOSED</text>
          <text x="500" y="424" textAnchor="middle" className="fill-secondary font-mono text-label">NEUTRAL</text>
          <text x="708" y="196" className="fill-secondary font-mono text-label">ALIGNED</text>

          {/*
            線を全部描いてから、ラベルを全部描く。1 本ずつ「線・ラベル」で並べると、後の線が
            前のラベルの上に来る —— SVG は文書の順に描くので、縁取りは自分より前に描かれた
            ものしか止められない(system-landscape-map で同じ並びが実測の事故になった)。
          */}
          {drawnEdges.map(({ edge, path, touchesPriority }) => (
            <path
              key={edge.id}
              d={path}
              fill="none"
              className={touchesPriority ? "stroke-tertiary" : "stroke-primary"}
              strokeWidth={edge.strokeWidth}
              vectorEffect="non-scaling-stroke"
              markerEnd={`url(#${markerId})`}
              data-primary-edge={edge.id}
            />
          ))}
          {geometry.nodes.map((node) => {
            const radius = node.radius * 3;
            const isPriority = node.id === priority.id;
            const nodeNumber = geometry.nodes.indexOf(node) + 1;
            return (
              <g
                key={node.id}
                tabIndex={0}
                data-stakeholder-record={node.id}
                data-primary-record={node.id}
                data-geometry-a={node.support}
                data-geometry-b={node.interest}
                data-primary-emphasis={isPriority ? "true" : undefined}
              >
                <title>{`${node.name} · ${node.role} · INF ${node.influence} · INT ${node.interest} · SUP ${node.support}`}</title>
                {isPriority && <circle cx={node.x} cy={node.y} r={radius + 12} className="fill-tertiary/15" />}
                <circle cx={node.x} cy={node.y} r={radius} className={isPriority ? "fill-tertiary" : "fill-primary"} />
                <circle cx={node.x} cy={node.y} r={Math.max(4, radius * 0.28)} className="fill-surface" />
                {/* 2 行の間隔は 23px 取る。18px(-4 と +14)では実測で 3.7px しか空かず、
                    判定の下限 3px をかろうじて超えているだけで、1 つの塊に見えていた。 */}
                <text data-primary-label={node.id} x={node.x} y={node.y + 3.5} textAnchor="middle" className="fill-on-primary font-mono font-semibold" fontSize="10">S{String(nodeNumber).padStart(2, "0")}</text>
              </g>
            );
          })}
        </svg>

        <div className="space-y-3 sm:hidden">
          {stakeholders.map((item) => (
            <article key={item.id} data-stakeholder-record={item.id} data-stakeholder-mobile-record={item.id} className={`grid grid-cols-[1fr_auto] gap-3 border-b py-3 ${item.id === priority.id ? "border-tertiary" : "border-outline"}`}>
              <div><strong>{item.name}</strong><p className="text-label text-secondary">{item.role}</p></div>
              <span className="font-mono text-label">INF {item.influence} · INT {item.interest} · SUP {item.support}</span>
            </article>
          ))}
          <div className="space-y-2 border-t border-outline pt-4" aria-label="関係一覧">
            {relations.map((relation, index) => {
              const source = stakeholders.find((item) => item.id === relation.sourceId)!;
              const target = stakeholders.find((item) => item.id === relation.targetId)!;
              return <p key={`${relation.sourceId}-${relation.targetId}-${index}`} className="break-words text-label text-secondary"><strong className="font-mono text-primary">R{String(index + 1).padStart(2, "0")}</strong> · {source.name} → {target.name} · {relation.label} · 強度{relation.strength}</p>;
            })}
          </div>
        </div>
      </div>

      <div className="stakeholder-network-key mt-5 hidden border-t border-outline pt-4 sm:block" aria-label="関係者と関係の一覧">
        <h2 className="mb-3 text-h3">関係者</h2>
        <div className="grid gap-x-6 gap-y-2 sm:grid-cols-2 xl:grid-cols-3">
          {stakeholders.map((item, index) => <article key={item.id} data-stakeholder-key={item.id} className="min-w-0 break-words border-b border-outline/60 py-2 text-label"><strong className="font-mono">S{String(index + 1).padStart(2, "0")} · {item.name}</strong><p className="text-secondary">{item.role} · INF {item.influence} · INT {item.interest} · SUP {item.support}</p></article>)}
        </div>
        <h2 className="mb-3 mt-5 text-h3">関係</h2>
        <div className="grid gap-x-6 gap-y-2 sm:grid-cols-2 xl:grid-cols-3">
          {relations.map((relation, index) => {
            const source = stakeholders.find((item) => item.id === relation.sourceId)!;
            const target = stakeholders.find((item) => item.id === relation.targetId)!;
            return <article key={`${relation.sourceId}-${relation.targetId}-${index}`} data-relation-key className="min-w-0 break-words border-b border-outline/60 py-2 text-label"><strong className="font-mono">R{String(index + 1).padStart(2, "0")} · {relation.label}</strong><p className="text-secondary">{source.name} → {target.name} · 強度{relation.strength}</p></article>;
          })}
        </div>
      </div>

      <div data-stakeholder-view="risk-ledger" className="mt-6 hidden group-has-[.stakeholder-view-risk:checked]/stakeholder:block">
        <div className="divide-y divide-outline border-y border-outline">
          {ranked.map((item, index) => (
            <article key={item.id} data-stakeholder-record={item.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>{item.name}</strong><p className="text-label text-secondary">影響{item.influence} × 関心{item.interest} × 支持不足{6 - item.support}</p></div>
              <span className="font-mono text-h3">{item.risk}</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.name}:</strong> {item.note}</p>)}
        </details>
      )}
    </section>
  );
}

schema.ts

1.6 KB

/designs/stakeholder-network/source/schema.ts

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

meta.yaml

1.9 KB

/designs/stakeholder-network/source/meta.yaml

中身を見る
id: stakeholder-network
name:
  ja: "ステークホルダー・ネットワーク(合意形成リスク)"
  en: "Stakeholder Network (Alignment Risk)"
purpose:
  ja: "支持度・関心度・影響力と関係線を一つのnetworkへ写し、最大の合意形成リスクを導出する"
  en: "Support, interest, influence and the relationships between people in one network, with the greatest risk to alignment read off it"
# A5 の裁定で draft として移設し、2026-09-04 に型ごとの承認を取って公開した。
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: governance
catalogOrder: 210
jobs:
  - assign-governance
  - prioritize-diagnose
  - explain-structure
useCases:
  - org
layoutKinds:
  - network
tags: [stakeholder, influence, network, alignment, governance]
whenToUse:
  - ja: "変革案件の合意形成順を決めたい場合"
    en: "Deciding the order to build agreement on a change"
  - ja: "公式組織図では見えない影響経路を説明したい場合"
    en: "Explaining influence that the formal org chart does not show"
avoidWhen:
  - ja: "責任記号を活動別に監査したい場合"
    en: "Auditing responsibility letters activity by activity"
  - ja: "2軸だけで関係者を位置づけたい場合"
    en: "Placing people on two axes alone"
requiredInputs:
  - ja: "利害関係者と役割"
    en: "The stakeholders and their roles"
  - ja: "影響力・関心度・支持度"
    en: "Their influence, interest and support"
  - ja: "関係者間の影響関係"
    en: "How they influence one another"
relatedLayouts: [raci-matrix, matrix-2x2, logic-tree]
dataSchema:
  stakeholders: "{ id, name, role, influence, interest, support, note? }[] — 4〜10件"
  relations: "{ sourceId, targetId, strength, label? }[] — 1〜16関係"
sources: []

sample-data.json

1.4 KB

/designs/stakeholder-network/source/sample-data.json

中身を見る
{
  "title": "財務部長との合意形成を最優先 — 高影響・高関心・支持度1",
  "subtitle": "基幹刷新プログラムの関係者仮説(各尺度は5段階)",
  "stakeholders": [
    {"id":"sponsor","name":"事業責任者","role":"スポンサー","influence":5,"interest":5,"support":4},
    {"id":"finance","name":"財務部長","role":"予算承認","influence":5,"interest":4,"support":1,"note":"投資回収と段階移行の根拠が不足し、全面刷新への懸念が強い想定"},
    {"id":"it","name":"IT部長","role":"実行責任","influence":5,"interest":5,"support":4},
    {"id":"users","name":"現場リーダー","role":"利用部門","influence":3,"interest":5,"support":3},
    {"id":"audit","name":"内部監査","role":"統制確認","influence":4,"interest":3,"support":2},
    {"id":"vendor","name":"導入ベンダー","role":"実行支援","influence":2,"interest":4,"support":5}
  ],
  "relations": [
    {"sourceId":"sponsor","targetId":"finance","strength":5,"label":"投資判断"},
    {"sourceId":"it","targetId":"sponsor","strength":4,"label":"実行報告"},
    {"sourceId":"users","targetId":"it","strength":4,"label":"業務要件"},
    {"sourceId":"audit","targetId":"finance","strength":3,"label":"統制意見"},
    {"sourceId":"vendor","targetId":"it","strength":3,"label":"技術支援"}
  ],
  "instanceId": "core-renewal-stakeholders"
}