索引に戻る

カスタマージャーニーマップ(体験ギャップ)

時系列
生成可能
カスタマージャーニーマップ(体験ギャップ)

作品として見る

持ち帰る

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

全部入り

customer-journey-map-everything.md

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

依頼文

1.4 KB
「カスタマージャーニーマップ(体験ギャップ)」でHTML資料を作ってください。この型は実行できるので、版面を組み直す必要はありません — 必要なのは中身です。

## この型がすること
顧客段階を横軸、満足度を脈線、接点とpain impactを同じstageへ結び、最大改善ギャップを導出する

## 渡す必要があるもの
- 顧客段階と行動
- 各段階の接点
- 満足度とpain impact

## 入力の形
- `stages` — { id, label, action, touchpoints[], satisfaction, painImpact, note? }[] — 3〜7段階

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

## 向かない場合
- 同一母集団の件数減少を分析したい場合
- 部門担当と工程時間を主題にしたい場合

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

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

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

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

ファイル

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

template.tsx

11.1 KB

/designs/customer-journey-map/source/template.tsx

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

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

// 2 つの識別子を `-` で繋ぐと単射にならない —— ("a-b","c") と ("a","b-c") が
// 同じ鍵になり、React の key と data 属性の identity が別物どうしで一致する。
// 区切りに使う文字を各辺で逃がしてから繋ぐ。
const joinIds = (...parts: (string | number)[]) =>
  parts.map((p) => String(p).replace(/[_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`)).join("-");

export function rankJourneyGaps(stages: JourneyStage[]) {
  return stages
    .map((stage, index) => ({
      ...stage,
      gap: stage.painImpact * (6 - stage.satisfaction),
      inputIndex: index,
    }))
    .sort((a, b) => b.gap - a.gap || a.inputIndex - b.inputIndex);
}

export function journeyPoints(stages: JourneyStage[]) {
  return stages.map((stage, index) => ({
    id: stage.id,
    xPercent: round(4 + (index / Math.max(stages.length - 1, 1)) * 92),
    yPercent: round(((5 - stage.satisfaction) / 4) * 100),
  }));
}

export function journeyGeometry(stages: JourneyStage[]) {
  const nodes = stages.map((stage, index) => ({
    ...stage,
    x: round(80 + (index / Math.max(stages.length - 1, 1)) * 840),
    y: round(70 + ((5 - stage.satisfaction) / 4) * 170),
    radius: round(9 + stage.painImpact * 3.5),
  }));
  const edges = nodes.slice(0, -1).map((node, index) => ({
    id: joinIds(node.id, nodes[index + 1].id),
    source: node,
    target: nodes[index + 1],
  }));
  return { nodes, edges };
}

export default function CustomerJourneyMap({
  title,
  subtitle,
  stages,
  instanceId,
}: CustomerJourneyMapProps) {
  // 既定を固定文字列にすると、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)}_`,
    ) || "customer-journey";
  const ranked = rankJourneyGaps(stages);
  const priority = ranked[0];
  const geometry = journeyGeometry(stages);
  const notes = stages.filter((stage) => stage.note);
  const markerId = `${uid}-journey-arrow`;

  return (
    <section
      className="asset group/journey overflow-hidden rounded-xl border border-outline bg-surface p-5 text-primary shadow-card sm:p-8"
      data-layout="customer-journey-map"
    >
      <header className="border-b border-outline pb-5">
        <p className="font-mono text-label text-tertiary">EXPERIENCE PULSE</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="journey-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="journey-view-gap sr-only" type="radio" name={`${uid}-view`} />改善課題
          </label>
        </div>
        <span className="font-mono text-label text-secondary">Y=SATISFACTION · SIZE=PAIN IMPACT · X=STAGE</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-journey-default-priority="true"
      >
        <span className="font-mono text-label text-tertiary">DEEPEST DIP</span>
        <strong>{priority.label}</strong>
        <span className="font-mono text-h3">GAP {priority.gap}</span>
      </div>

      <div data-journey-view="map" className="mt-6 group-has-[.journey-view-gap:checked]/journey:hidden">
        <svg
          className="hidden h-80 w-full sm:block"
          viewBox="0 0 1000 350"
          role="img"
          aria-label="顧客体験の満足度推移と痛点影響度を一つの脈線で示すジャーニー"
          data-primary-geometry="customer-journey-map"
          data-geometry-channels="stage-position satisfaction-height pain-radius gap-emphasis"
        >
          <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>

          {[5, 3, 1].map((score) => {
            const y = 70 + ((5 - score) / 4) * 170;
            return (
              <g key={score}>
                <line x1="66" x2="934" y1={y} y2={y} className="stroke-outline" strokeDasharray={score === 3 ? "5 7" : undefined} vectorEffect="non-scaling-stroke" />
                <text x="54" y={y + 4} textAnchor="end" className="fill-secondary font-mono text-label">S{score}</text>
              </g>
            );
          })}

          {geometry.edges.map((edge) => {
            const dx = edge.target.x - edge.source.x;
            const dy = edge.target.y - edge.source.y;
            const distance = Math.hypot(dx, dy) || 1;
            const ux = dx / distance;
            const uy = dy / distance;
            const startX = edge.source.x + ux * (edge.source.radius + 5);
            const startY = edge.source.y + uy * (edge.source.radius + 5);
            const endX = edge.target.x - ux * (edge.target.radius + 10);
            const endY = edge.target.y - uy * (edge.target.radius + 10);
            const touchesPriority = edge.source.id === priority.id || edge.target.id === priority.id;
            return (
              <path
                key={edge.id}
                d={`M ${startX} ${startY} L ${endX} ${endY}`}
                fill="none"
                className={touchesPriority ? "stroke-tertiary" : "stroke-primary"}
                strokeWidth={touchesPriority ? 4 : 2.5}
                vectorEffect="non-scaling-stroke"
                markerEnd={`url(#${markerId})`}
                data-primary-edge={edge.id}
              />
            );
          })}

          {geometry.nodes.map((node, index) => {
            const isPriority = node.id === priority.id;
            const labelY = Math.min(290, node.y + node.radius + 22);
            return (
              <g
                key={node.id}
                tabIndex={0}
                data-journey-stage={node.id}
                data-primary-record={node.id}
                data-geometry-a={node.satisfaction}
                data-geometry-b={node.painImpact}
                data-primary-emphasis={isPriority ? "true" : undefined}
              >
                {isPriority && <circle cx={node.x} cy={node.y} r={node.radius + 13} className="fill-tertiary/15" />}
                <circle cx={node.x} cy={node.y} r={node.radius} className={isPriority ? "fill-tertiary" : "fill-primary"} />
                <circle cx={node.x} cy={node.y} r={Math.max(4, node.radius * 0.3)} className="fill-surface" />
                <title>{node.action}</title>
                <text x={node.x} y="310" textAnchor="middle" className="fill-primary font-mono text-label font-semibold">J{String(index + 1).padStart(2, "0")}</text>
              </g>
            );
          })}
        </svg>

        <div className="journey-desktop-ledger mt-4 hidden divide-y divide-outline border-y border-outline sm:block">
          {stages.map((stage, index) => {
            const gap = ranked.find((item) => item.id === stage.id)!.gap;
            return (
              <article key={stage.id} data-journey-desktop-stage={stage.id} data-journey-stage={stage.id} data-primary-record={stage.id} className="grid grid-cols-[3.5rem_minmax(0,1fr)] gap-3 py-3">
                <span className="font-mono text-label text-tertiary">J{String(index + 1).padStart(2, "0")}</span>
                <div className="min-w-0 break-words">
                  <strong data-primary-label={stage.id}>{stage.label}</strong>
                  <p className="text-label text-secondary">{stage.action}</p>
                  <p className="mt-1 text-label text-secondary">{stage.touchpoints.join(" / ")}</p>
                  <p className="mt-1 font-mono text-label">満足度 {stage.satisfaction} · 痛点影響 {stage.painImpact} · GAP {gap}</p>
                </div>
              </article>
            );
          })}
        </div>

        <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" />
          {stages.map((stage, index) => (
            <article key={stage.id} data-journey-stage={stage.id} data-journey-mobile-stage={stage.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 ${stage.id === priority.id ? "bg-tertiary ring-4 ring-tertiary/20" : "bg-primary"}`} aria-hidden="true" />
              <span className="font-mono text-label text-tertiary">J{index + 1}</span>
              <div className="min-w-0 break-words"><strong>{stage.label}</strong><p className="text-label text-secondary">{stage.action}</p><p className="mt-1 text-label text-secondary">{stage.touchpoints.join(" / ")}</p></div>
              <span className="font-mono text-label">S{stage.satisfaction} · I{stage.painImpact} · G{ranked.find((item) => item.id === stage.id)?.gap}</span>
            </article>
          ))}
        </div>
      </div>

      <div data-journey-view="gap-ledger" className="mt-6 hidden group-has-[.journey-view-gap:checked]/journey:block">
        <div className="divide-y divide-outline border-y border-outline">
          {ranked.map((stage, index) => (
            <article key={stage.id} data-journey-stage={stage.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>{stage.label}</strong><p className="text-label text-secondary">影響{stage.painImpact} × 満足不足{6 - stage.satisfaction} · {stage.touchpoints.join(" / ")}</p></div>
              <span className="font-mono text-h3">{stage.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((stage) => <p key={stage.id} data-note-item="true" className="mt-3 text-body text-secondary"><strong className="text-primary">{stage.label}:</strong> {stage.note}</p>)}
        </details>
      )}
    </section>
  );
}

schema.ts

1.1 KB

/designs/customer-journey-map/source/schema.ts

中身を見る
import { z } from "zod";
export const journeyStageSchema = z.object({ id: z.string().min(1), label: z.string().min(1), action: z.string().min(1), touchpoints: z.array(z.string().min(1)).min(1).max(4), satisfaction: z.number().int().min(1).max(5), painImpact: 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(), stages: z.array(journeyStageSchema).min(3).max(7), instanceId: z.string().optional() }).strict().superRefine((value, ctx) => {
  // id は identity として使う(React の key、data 属性、対応強調の突き合わせ)。
  // 重複すると別物どうしが同じ要素を指し、操作が意図しない相手に効く。
  {
    const ids = value.stages.map((item) => item.id);
    if (new Set(ids).size !== ids.length) {
      ctx.addIssue({ code: "custom", path: ["stages"], message: "stages ids must be unique" });
    }
  }
});
export type JourneyStage = z.infer<typeof journeyStageSchema>;
export type CustomerJourneyMapProps = z.infer<typeof schema>;

meta.yaml

1.8 KB

/designs/customer-journey-map/source/meta.yaml

中身を見る
id: customer-journey-map
name:
  ja: "カスタマージャーニーマップ(体験ギャップ)"
  en: "Customer Journey Map (Experience Gap)"
purpose:
  ja: "顧客段階を横軸、満足度を脈線、接点とpain impactを同じstageへ結び、最大改善ギャップを導出する"
  en: "Stages along the axis, satisfaction as a pulse line, touchpoints and pain tied to the same stage, and the largest gap to close read off them"
# A5 の裁定で draft として移設し、2026-09-04 に型ごとの承認を取って公開した。
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: experience
catalogOrder: 170
jobs:
  - trace-flow
  - prioritize-diagnose
useCases:
  - case-study
  - process
layoutKinds:
  - timeline
tags: [customer-journey, experience, touchpoint, satisfaction, pain-point]
whenToUse:
  - ja: "顧客体験の山谷と改善優先点を一続きで説明したい場合"
    en: "Explaining the peaks and troughs of an experience and what to fix first"
  - ja: "接点横断の体験設計を合意したい場合"
    en: "Agreeing an experience design across touchpoints"
avoidWhen:
  - ja: "同一母集団の件数減少を分析したい場合"
    en: "Analysing how a single population falls in count"
  - ja: "部門担当と工程時間を主題にしたい場合"
    en: "Making owners and step durations the subject"
requiredInputs:
  - ja: "顧客段階と行動"
    en: "The stages and what the customer does"
  - ja: "各段階の接点"
    en: "The touchpoints at each"
  - ja: "満足度とpain impact"
    en: "Satisfaction and the impact of each pain"
relatedLayouts: [conversion-funnel, timeline, heatmap]
dataSchema:
  stages: "{ id, label, action, touchpoints[], satisfaction, painImpact, note? }[] — 3〜7段階"
sources: []

sample-data.json

1.2 KB

/designs/customer-journey-map/source/sample-data.json

中身を見る
{
  "title": "申込体験が最大の離脱要因 — 入力負荷と確認待ちを短縮する",
  "subtitle": "法人顧客オンボーディング仮説(満足度・影響度は5段階)",
  "stages": [
    {"id":"discover","label":"情報収集","action":"導入事例と料金を比較する","touchpoints":["Web","資料"],"satisfaction":4,"painImpact":2},
    {"id":"consult","label":"相談","action":"要件と適用範囲を確認する","touchpoints":["営業","オンライン会議"],"satisfaction":4,"painImpact":3},
    {"id":"apply","label":"申込","action":"企業情報と利用条件を入力する","touchpoints":["申込フォーム","メール"],"satisfaction":1,"painImpact":5,"note":"同じ企業情報を複数画面で再入力し、審査状況も確認できない想定"},
    {"id":"setup","label":"初期設定","action":"管理者と権限を登録する","touchpoints":["管理画面","導入支援"],"satisfaction":3,"painImpact":4},
    {"id":"adopt","label":"定着","action":"利用状況を確認し改善する","touchpoints":["ダッシュボード","CS"],"satisfaction":4,"painImpact":3}
  ],
  "instanceId": "b2b-onboarding-journey"
}