Back to index

Stakeholder Network (Alignment Risk)

Network
Generates documents
Stakeholder Network (Alignment Risk)

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

stakeholder-network-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 "Stakeholder Network (Alignment Risk)". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
Support, interest, influence and the relationships between people in one network, with the greatest risk to alignment read off it

## What you have to supply
- The stakeholders and their roles
- Their influence, interest and support
- How they influence one another

## The shape of the input
- `stakeholders` — { id, name, role, influence, interest, support, note? }[] — 4〜10件
- `relations` — { sourceId, targetId, strength, label? }[] — 1〜16関係

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

## When not to use it
- Auditing responsibility letters activity by activity
- Placing people on two axes alone

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

```bash
node scripts/render.mjs stakeholder-network <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/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

Files

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

template.tsx

21.5 KB

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

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

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

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

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