営業日報の自動集計
事業効果 74 ・ 実行容易性 82
既存SaaSの標準機能で実現可能。想定工期2週間、効果は営業部門全体に波及する
型番 matrix-2x2
項目を2軸のスコアで座標平面に自動配置し、優先順位やポジショニングをデータ駆動で示す。補足は図の下で明示的に展開し、マップ⇄リストを切り替えられる
matrix · positioning · prioritization · quadrant · data-driven · interactive
LIVE SAMPLE
補足の開閉・表示切替を試せます
ALTERNATIVES
型番 heatmap
業務領域と評価軸の交点をスコアの濃淡で示し、最高スコアを行・列の座標照準として1件強調する。同じcell identityと座標locatorを保ったまま全体分布から重点課題上位5件へ切り替えられ、補足は図を覆わず下で確認できる
型番 comparison-table
製品・ベンダーを評価軸ごとに比較する表。○の数が最も多い列をデータから自動判定し、見出し・各評価の加点/非加点/参考・集計を一続きの推奨spineで示す。390pxでは評価軸ごとの全候補ledgerへ転置し、補足は表の下で明示的に展開できる
型番 kpi-cards
代表KPIの現在値・前期比・目標達成・推移を一つの主分析面で示し、残りを比較ledgerへ整理する。実データ系列から SVG スパークラインを自動生成し、目標を破線で重ねて達成/未達を自動判定する。解約率のような「下がるほど良い」指標も betterWhen で正しく着色される
import { useId } from "react";
import type { Matrix2x2Props, MatrixItem } from "./schema";
/** スコアを 0-100 に収める(範囲外データで点が枠外に出るのを防ぐ) */
export const clampScore = (n: number) => Math.min(100, Math.max(0, n));
/** 象限番号 [左上=0, 右上=1, 左下=2, 右下=3]。境界値 50 は高評価側(右・上)に入る */
export const quadrantIndex = (it: Pick<MatrixItem, "x" | "y">) =>
(it.y >= 50 ? 0 : 2) + (it.x >= 50 ? 1 : 0);
/** highlight 要素専用グラデーション(DESIGN.md: indigo→violet 限定・1件のみ) */
const HIGHLIGHT_GRADIENT = "bg-linear-to-br from-tertiary to-accent-violet";
/** エントランスの時差。間隔は global.css の --stagger-step で統一 */
const stagger = (i: number, baseMs = 0) =>
`calc(${baseMs}ms + ${i} * var(--stagger-step))`;
function AxisLabel({
vertical,
children,
}: {
vertical?: boolean;
children: string;
}) {
return (
<span
className={`text-label text-secondary${vertical ? " [writing-mode:vertical-rl]" : ""}`}
>
{children}
</span>
);
}
/** 象限のゴーストラベル。半区画の中央に display サイズ・透過5%で敷く */
function Zone({
corner,
tinted,
index,
children,
}: {
corner: string;
tinted?: boolean;
index: number;
children: string;
}) {
return (
<div
className={`absolute flex h-1/2 w-1/2 items-center justify-center ${corner}${tinted ? " bg-tertiary/5" : ""}`}
>
<span
className="animate-rise select-none text-h2 text-primary/5 sm:text-display"
style={{ animationDelay: stagger(index, 100) }}
>
{children}
</span>
</div>
);
}
export default function Matrix2x2({
title,
subtitle,
xAxis,
yAxis,
zones,
items,
instanceId,
}: Matrix2x2Props) {
const autoId = useId();
const uid = instanceId ?? autoId;
const notes = items.filter((item) => item.note);
// リスト投影は同じ items から導出する(単一データソース・2つの投影)
const buckets: [string, MatrixItem[]][] = zones.map((z) => [z, []]);
for (const it of items) buckets[quadrantIndex(it)][1].push(it);
return (
<section
data-matrix-root="true"
className="group/matrix mx-auto max-w-slide bg-surface p-12 font-sans text-on-surface"
>
{/* 表示切替は CSS のみ(radio + :has())。成果物の単一ファイル制約を守る */}
<input
type="radio"
name={uid}
id={`${uid}-map`}
className="v-map sr-only"
defaultChecked
/>
<input
type="radio"
name={uid}
id={`${uid}-list`}
className="v-list sr-only"
/>
<header className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-h1">{title}</h1>
{subtitle && (
<p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
)}
</div>
<div className="inline-flex rounded-full border border-outline bg-surface p-1 shadow-card">
<label
htmlFor={`${uid}-map`}
className="cursor-pointer rounded-full px-4 py-1.5 text-label text-secondary group-has-[.v-map:checked]/matrix:bg-primary group-has-[.v-map:checked]/matrix:text-on-primary group-has-[.v-map:focus-visible]/matrix:ring-2 group-has-[.v-map:focus-visible]/matrix:ring-tertiary"
>
マップ
</label>
<label
htmlFor={`${uid}-list`}
className="cursor-pointer rounded-full px-4 py-1.5 text-label text-secondary group-has-[.v-list:checked]/matrix:bg-primary group-has-[.v-list:checked]/matrix:text-on-primary group-has-[.v-list:focus-visible]/matrix:ring-2 group-has-[.v-list:focus-visible]/matrix:ring-tertiary"
>
リスト
</label>
</div>
</header>
{/* ── マップ投影: x/y スコアによるデータ駆動配置 ── */}
<div className="hidden gap-3 group-has-[.v-map:checked]/matrix:flex">
<div className="flex flex-col items-center justify-between py-2">
<AxisLabel>{yAxis.high}</AxisLabel>
<AxisLabel vertical>{yAxis.label}</AxisLabel>
<AxisLabel>{yAxis.low}</AxisLabel>
</div>
<div className="flex-1">
<div className="relative aspect-[16/9] rounded-lg border border-outline bg-surface">
<Zone corner="left-0 top-0 rounded-tl-lg" index={0}>
{zones[0]}
</Zone>
<Zone corner="right-0 top-0 rounded-tr-lg" tinted index={1}>
{zones[1]}
</Zone>
<Zone corner="bottom-0 left-0 rounded-bl-lg" index={2}>
{zones[2]}
</Zone>
<Zone corner="bottom-0 right-0 rounded-br-lg" index={3}>
{zones[3]}
</Zone>
<div className="absolute inset-y-0 left-1/2 w-px bg-outline" />
<div className="absolute inset-x-0 top-1/2 h-px bg-outline" />
{items.map((it, i) => {
const cx = clampScore(it.x);
const cy = clampScore(it.y);
return (
<div
key={i}
data-matrix-item={i}
className="group/dot absolute -translate-x-1/2 translate-y-1/2 animate-pop"
style={{
left: `${cx}%`,
bottom: `${cy}%`,
animationDelay: stagger(i, 400),
}}
>
<span
className={`block rounded-full shadow-card ${
it.highlight
? `size-4.5 ring-4 ring-tertiary/25 ${HIGHLIGHT_GRADIENT}`
: "size-3.5 bg-tertiary ring-4 ring-tertiary/10"
}`}
/>
<span className="absolute left-1/2 top-full mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-full bg-surface/90 px-2 py-0.5 text-label shadow-card">
{it.label}
</span>
</div>
);
})}
</div>
<div className="mt-3 flex items-center justify-between">
<AxisLabel>{xAxis.low}</AxisLabel>
<AxisLabel>{xAxis.label}</AxisLabel>
<AxisLabel>{xAxis.high}</AxisLabel>
</div>
</div>
</div>
{/* ── リスト投影: 同じ items を象限ごとに束ねて表示 ── */}
<div className="hidden grid-cols-1 gap-3 sm:grid-cols-2 group-has-[.v-list:checked]/matrix:grid">
{buckets.map(([zone, zoneItems], i) => (
<div
key={i}
className={`animate-rise rounded-lg p-6 ${
i === 1
? "bg-tertiary-container text-on-tertiary-container"
: "border border-outline bg-surface-subtle"
}`}
style={{ animationDelay: stagger(i) }}
>
<h2 className="text-h3">{zone}</h2>
<ul className="mt-3 space-y-3">
{zoneItems.map((it, j) => (
<li key={j} className="flex gap-2">
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${
it.highlight ? HIGHLIGHT_GRADIENT : "bg-tertiary/60"
}`}
/>
<div className="flex-1">
<p className="text-body-sm">{it.label}</p>
{it.note && (
<p className="mt-0.5 text-label opacity-70">{it.note}</p>
)}
</div>
</li>
))}
{zoneItems.length === 0 && (
<li className="text-body-sm opacity-70">該当なし</li>
)}
</ul>
</div>
))}
</div>
{notes.length > 0 && (
<details
data-inline-disclosure="true"
className="group mt-6 rounded-lg border border-outline bg-surface-subtle"
>
<summary className="flex cursor-pointer list-none items-center px-4 py-3 text-label text-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary">
<span>補足を確認</span>
<span className="ml-auto font-mono">{notes.length}件</span>
<span aria-hidden="true" className="ml-2 text-tertiary transition-transform group-open:rotate-45">+</span>
</summary>
<div className="grid gap-3 border-t border-outline p-4 sm:grid-cols-2">
{notes.map((item, index) => (
<article key={index} data-note-item="true" className="rounded-md bg-surface p-4">
<h3 className="text-label">{item.label}</h3>
<p className="mt-1 font-mono text-label text-secondary">
{yAxis.label} {item.y} ・ {xAxis.label} {item.x}
</p>
<p className="mt-2 text-body-sm">{item.note}</p>
</article>
))}
</div>
</details>
)}
</section>
);
}
import { z } from "zod";
export const axisSchema = z
.object({
label: z.string().describe("軸の名称(例: 事業効果)"),
low: z.string().describe("軸の低い側のラベル(例: 小)"),
high: z.string().describe("軸の高い側のラベル(例: 大)"),
})
.strict();
export const matrixItemSchema = z
.object({
label: z.string(),
x: z.number().describe("横軸スコア 0-100。座標平面への配置に使う"),
y: z.number().describe("縦軸スコア 0-100。座標平面への配置に使う"),
note: z.string().optional().describe("図の下の補足欄で明示的に展開される詳細"),
highlight: z
.boolean()
.optional()
.describe(
"最重要の1件のみ true(グラデーション表示。DESIGN.md: 大胆さは1画面1箇所)",
),
})
.strict();
export const schema = z
.object({
title: z.string().describe("スライドタイトル。結論を書くメッセージラインを推奨"),
subtitle: z.string().optional().describe("補足・出典など"),
xAxis: axisSchema,
yAxis: axisSchema,
zones: z
.tuple([z.string(), z.string(), z.string(), z.string()])
.describe(
"象限ラベル [左上, 右上, 左下, 右下]。リスト表示の見出しにも使う",
),
items: z.array(matrixItemSchema),
instanceId: z
.string()
.optional()
.describe("同一ページに複数インスタンスを置くときの radio グループ識別子"),
})
.strict()
.refine(
(data) => data.items.filter((it) => it.highlight === true).length <= 1,
{
message: "highlight は最大1件のみ true にすること(DESIGN.md: 大胆さは1画面1箇所)",
path: ["items"],
},
);
export type Axis = z.infer<typeof axisSchema>;
export type MatrixItem = z.infer<typeof matrixItemSchema>;
export type Matrix2x2Props = z.infer<typeof schema>;
{
"title": "DX施策の優先度マップ — 右上の3件から着手する",
"subtitle": "全8施策を効果と実行容易性の2軸で評価(2026年度 IT投資計画・サンプル)",
"xAxis": { "label": "実行容易性", "low": "低", "high": "高" },
"yAxis": { "label": "事業効果", "low": "小", "high": "大" },
"zones": ["計画的に投資", "最優先で着手", "着手しない", "余力があれば実施"],
"items": [
{
"label": "営業日報の自動集計",
"x": 82,
"y": 74,
"note": "既存SaaSの標準機能で実現可能。想定工期2週間、効果は営業部門全体に波及する",
"highlight": true
},
{
"label": "請求書ワークフロー",
"x": 68,
"y": 81,
"note": "経理の月次残業を約3割削減見込み。ワークフロー製品の導入のみで完結する"
},
{
"label": "社内FAQボット",
"x": 74,
"y": 56,
"note": "問い合わせの6割が定型質問。既存ナレッジの整備が前提条件"
},
{
"label": "基幹システム刷新",
"x": 22,
"y": 92,
"note": "効果は最大だが3年計画・投資規模大。段階移行の計画策定から着手する"
},
{
"label": "データ基盤の統合",
"x": 35,
"y": 78,
"note": "部門横断の合意形成が必要。基幹刷新と並走で計画する"
},
{
"label": "全社ポータル再構築",
"x": 28,
"y": 30,
"note": "効果・容易性ともに低い。今期は見送り"
},
{
"label": "会議室予約の刷新",
"x": 76,
"y": 26,
"note": "実装は容易だが効果は限定的"
},
{
"label": "社内Wiki移行",
"x": 64,
"y": 33,
"note": "移行コストは小さい。ドキュメント整備と合わせて実施の余地あり"
}
]
}