Back to index

Value Chain Map (Room to Improve)

Flow
Generates documents
Value Chain Map (Room to Improve)

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

value-chain-map-everything.md

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

Prompt

1.3 KB
Build an HTML document with "Value Chain Map (Room to Improve)". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
Primary activities with cost share as width and value contribution as height, so the largest room to improve value follows from the shape

## What you have to supply
- The primary and supporting activities
- The cost of each
- The value each contributes

## The shape of the input
- `primaryActivities` — { id, label, cost, value, note? }[] — 4〜8主活動
- `supportActivities` — string[] — 1〜4支援活動

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

## When not to use it
- Showing what moved a value from a start to an end
- Nesting capabilities inside business domains

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

```bash
node scripts/render.mjs value-chain-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/value-chain-map/source/template.tsx
- https://design-vault.pages.dev/designs/value-chain-map/source/schema.ts
- https://design-vault.pages.dev/designs/value-chain-map/source/sample-data.json
- https://design-vault.pages.dev/designs/value-chain-map/sample.html

Files

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

template.tsx

11.2 KB

/designs/value-chain-map/source/template.tsx

Show contents
import { useId } from "react";
import type { ValueActivity, ValueChainMapProps } from "./schema";

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

// 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 function activityGeometry(activities: ValueActivity[]) {
  const total = activities.reduce((sum, item) => sum + item.cost, 0);
  let cumulative = 0;
  return activities.map((item) => {
    const startPercent = round((cumulative / total) * 100);
    cumulative += item.cost;
    const endPercent = round((cumulative / total) * 100);
    return {
      ...item,
      startPercent,
      widthPercent: round((item.cost / total) * 100),
      endPercent,
      heightPercent: (item.value / 5) * 100,
    };
  });
}

export function rankValueGaps(activities: ValueActivity[]) {
  return activities
    .map((item, index) => ({
      ...item,
      gap: item.cost * (6 - item.value),
      inputIndex: index,
    }))
    .sort((a, b) => b.gap - a.gap || a.inputIndex - b.inputIndex);
}

export default function ValueChainMap({
  title,
  subtitle,
  costUnit,
  supportActivities,
  primaryActivities,
  instanceId,
}: ValueChainMapProps) {
  // 既定を固定文字列にすると、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)}_`,
    ) || "value-chain";
  const geometry = activityGeometry(primaryActivities).map((activity) => ({
    ...activity,
    startX: round(80 + activity.startPercent * 8.6),
    endX: round(80 + activity.endPercent * 8.6),
    halfHeight: 14 + activity.value * 13,
  }));
  const ranked = rankValueGaps(primaryActivities);
  const priority = ranked[0];
  const notes = primaryActivities.filter((item) => item.note);
  const markerId = `${uid}-value-arrow`;
  const centerY = 232;

  return (
    <section
      className="asset group/value overflow-hidden rounded-xl border border-outline bg-surface p-5 text-primary shadow-card sm:p-8"
      data-layout="value-chain-map"
    >
      <header className="border-b border-outline pb-5">
        <p className="font-mono text-label text-tertiary">VALUE RIVERBED</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="value-view-chain 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="value-view-gap sr-only" type="radio" name={`${uid}-view`} />改善余地
          </label>
        </div>
        <span className="font-mono text-label text-secondary">LENGTH=COST · THICKNESS=VALUE · RAIL=SUPPORT</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-value-chain-default-priority="true"
      >
        <span className="font-mono text-label text-tertiary">IMPROVE FIRST</span>
        <strong>{priority.label}</strong>
        <span className="font-mono text-h3">GAP {priority.gap}</span>
      </div>

      <div data-value-chain-view="chain" className="mt-6 group-has-[.value-view-gap:checked]/value:hidden">
        <svg
          className="hidden h-80 w-full sm:block"
          viewBox="0 0 1000 360"
          role="img"
          aria-label="コストを長さ、価値を太さとして連続表示するバリューチェーン"
          data-primary-geometry="value-chain-map"
          data-geometry-channels="cost-length value-thickness sequence-position support-rail gap-emphasis"
        >
          <defs>
            <marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="10" markerHeight="10" markerUnits="userSpaceOnUse" orient="auto">
              <path d="M 0 0 L 10 5 L 0 10 z" className="fill-surface" />
            </marker>
          </defs>

          {supportActivities.map((item, index) => {
            const y = 35 + index * 28;
            return (
              <g key={item}>
                <line x1="80" x2="940" y1={y} y2={y} className="stroke-outline" strokeWidth="6" strokeLinecap="round" />
                <circle cx="80" cy={y} r="6" className="fill-primary" />
                <text x="94" y={y + 4} className="fill-secondary font-mono text-label">S{index + 1} · {item}</text>
              </g>
            );
          })}

          <text x="80" y="116" className="fill-secondary font-mono text-label">PRIMARY VALUE STREAM</text>
          {geometry.map((activity, index) => {
            const isPriority = activity.id === priority.id;
            const centerX = (activity.startX + activity.endX) / 2;
            const previous = geometry[index - 1];
            const next = geometry[index + 1];
            const startHalf = previous
              ? (previous.halfHeight + activity.halfHeight) / 2
              : activity.halfHeight;
            const endHalf = next
              ? (activity.halfHeight + next.halfHeight) / 2
              : activity.halfHeight;
            const labelAbove = index % 2 === 0;
            const labelY = labelAbove ? centerY - activity.halfHeight - 44 : 335;
            const edgeY = labelAbove ? centerY - activity.halfHeight : centerY + activity.halfHeight;
            const ribbonPoints = `${activity.startX},${centerY - startHalf} ${centerX},${centerY - activity.halfHeight} ${activity.endX},${centerY - endHalf} ${activity.endX},${centerY + endHalf} ${centerX},${centerY + activity.halfHeight} ${activity.startX},${centerY + startHalf}`;
            const haloPoints = `${activity.startX - 6},${centerY - startHalf - 9} ${centerX},${centerY - activity.halfHeight - 9} ${activity.endX + 6},${centerY - endHalf - 9} ${activity.endX + 6},${centerY + endHalf + 9} ${centerX},${centerY + activity.halfHeight + 9} ${activity.startX - 6},${centerY + startHalf + 9}`;
            return (
              <g
                key={activity.id}
                tabIndex={0}
                data-value-chain-activity={activity.id}
                data-primary-record={activity.id}
                data-geometry-a={activity.cost}
                data-geometry-b={activity.value}
                data-primary-emphasis={isPriority ? "true" : undefined}
              >
                {isPriority && (
                  <polygon
                    points={haloPoints}
                    className="fill-tertiary/15"
                  />
                )}
                <polygon
                  points={ribbonPoints}
                  className={isPriority ? "fill-tertiary" : "fill-primary"}
                />
                <line x1={centerX} x2={centerX} y1={edgeY} y2={labelAbove ? labelY + 8 : labelY - 20} className="stroke-outline" vectorEffect="non-scaling-stroke" />
                <text data-primary-label={activity.id} x={centerX} y={labelY} textAnchor="middle" className="fill-primary text-label font-semibold">{index + 1} · {activity.label}</text>
                <text x={centerX} y={labelY + 17} textAnchor="middle" className="fill-secondary font-mono text-label">{activity.cost}{costUnit} · V{activity.value}</text>
              </g>
            );
          })}

          {geometry.slice(0, -1).map((activity, index) => {
            const next = geometry[index + 1];
            const startX = (activity.startX + activity.endX) / 2 + 12;
            const endX = (next.startX + next.endX) / 2 - 12;
            return (
              <path
                key={joinIds(activity.id, next.id)}
                d={`M ${startX} ${centerY} H ${endX}`}
                fill="none"
                className="stroke-surface"
                strokeWidth="3"
                vectorEffect="non-scaling-stroke"
                markerEnd={`url(#${markerId})`}
                data-primary-edge={joinIds(activity.id, next.id)}
              />
            );
          })}
        </svg>

        <div className="relative space-y-3 pl-7 sm:hidden">
          <span className="absolute bottom-2 left-2 top-2 w-px bg-outline" aria-hidden="true" />
          {primaryActivities.map((activity, index) => (
            <article key={activity.id} data-value-chain-activity={activity.id} data-value-chain-mobile-activity={activity.id} className="relative grid grid-cols-[3rem_1fr_auto] gap-3 border-b border-outline py-3 pl-3">
              <span className={`absolute -left-6 top-4 h-3 w-3 rounded-full ${activity.id === priority.id ? "bg-tertiary ring-4 ring-tertiary/20" : "bg-primary"}`} aria-hidden="true" />
              <span className="font-mono text-label text-tertiary">P{index + 1}</span>
              <div><strong>{activity.label}</strong><div className="mt-2 h-1.5 bg-outline"><span className="block h-full bg-primary" style={{ width: `${geometry[index].widthPercent}%` }} /></div></div>
              <span className="font-mono text-label">{activity.cost}{costUnit} · V{activity.value}</span>
            </article>
          ))}
        </div>
      </div>

      <div data-value-chain-view="gap-ledger" className="mt-6 hidden group-has-[.value-view-gap:checked]/value:block">
        <div className="divide-y divide-outline border-y border-outline">
          {ranked.map((activity, index) => (
            <article key={activity.id} data-value-chain-activity={activity.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>{activity.label}</strong><p className="text-label text-secondary">{activity.cost}{costUnit} × 価値不足{6 - activity.value}</p></div>
              <span className="font-mono text-h3">{activity.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

1.1 KB

/designs/value-chain-map/source/schema.ts

Show contents
import { z } from "zod";
export const valueActivitySchema = z.object({ id: z.string().min(1), label: z.string().min(1), cost: z.number().positive(), value: z.number().int().min(1).max(5), note: z.string().optional() }).strict();
export const schema = z.object({ title: z.string().min(1), subtitle: z.string().optional(), costUnit: z.string().min(1), supportActivities: z.array(z.string().min(1)).min(1).max(4), primaryActivities: z.array(valueActivitySchema).min(4).max(8), instanceId: z.string().optional() }).strict().superRefine((value, ctx) => {
  // id は identity として使う(React の key、data 属性、対応強調の突き合わせ)。
  // 重複すると別物どうしが同じ要素を指し、操作が意図しない相手に効く。
  {
    const ids = value.primaryActivities.map((item) => item.id);
    if (new Set(ids).size !== ids.length) {
      ctx.addIssue({ code: "custom", path: ["primaryActivities"], message: "primaryActivities ids must be unique" });
    }
  }
});
export type ValueActivity = z.infer<typeof valueActivitySchema>;
export type ValueChainMapProps = z.infer<typeof schema>;

meta.yaml

1.8 KB

/designs/value-chain-map/source/meta.yaml

Show contents
id: value-chain-map
name:
  ja: "バリューチェーンマップ(改善余地)"
  en: "Value Chain Map (Room to Improve)"
purpose:
  ja: "主活動の幅をコスト比率、高さを価値寄与へ写し、最大の価値改善余地を導出する"
  en: "Primary activities with cost share as width and value contribution as height, so the largest room to improve value follows from the shape"
# A5 の裁定で draft として移設し、2026-09-04 に型ごとの承認を取って公開した。
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: operating-model
catalogOrder: 200
jobs:
  - explain-structure
  - prioritize-diagnose
  - measure-change
useCases:
  - process
layoutKinds:
  - flow
tags: [value-chain, operating-model, cost, value, improvement]
whenToUse:
  - ja: "業務活動のコスト配分と価値を同時に見直したい場合"
    en: "Revisiting how cost is spread across activities alongside the value they add"
  - ja: "オペレーティングモデルの改善順を説明したい場合"
    en: "Explaining the order to improve an operating model"
avoidWhen:
  - ja: "起点から終点への増減要因を示したい場合"
    en: "Showing what moved a value from a start to an end"
  - ja: "能力を事業ドメインへ包含したい場合"
    en: "Nesting capabilities inside business domains"
requiredInputs:
  - ja: "主活動と支援活動"
    en: "The primary and supporting activities"
  - ja: "活動ごとのコスト"
    en: "The cost of each"
  - ja: "活動ごとの価値寄与"
    en: "The value each contributes"
relatedLayouts: [business-capability-map, waterfall, process-flow]
dataSchema:
  primaryActivities: "{ id, label, cost, value, note? }[] — 4〜8主活動"
  supportActivities: "string[] — 1〜4支援活動"
sources: []

sample-data.json

834 B

/designs/value-chain-map/source/sample-data.json

Show contents
{
  "title": "設計・開発の改善余地が最大 — 30人月を価値2から引き上げる",
  "subtitle": "デジタルサービス提供活動の仮説評価",
  "costUnit": "人月",
  "supportActivities": ["共通データ基盤", "人材・標準", "調達・契約"],
  "primaryActivities": [
    {"id":"insight","label":"顧客理解","cost":12,"value":4},
    {"id":"plan","label":"企画","cost":18,"value":3},
    {"id":"build","label":"設計・開発","cost":30,"value":2,"note":"個別要件と手戻りが集中し、再利用可能な標準部品が少ない想定"},
    {"id":"launch","label":"提供開始","cost":14,"value":4},
    {"id":"operate","label":"運用・支援","cost":20,"value":3},
    {"id":"improve","label":"分析・改善","cost":10,"value":5}
  ],
  "instanceId": "service-value-chain"
}