初期費用
C02推奨列
B社
¥50,000
参考
C01
A社(現行)
¥0
C03
C社
¥30,000
型番 comparison-table
製品・ベンダーを評価軸ごとに比較する表。○の数が最も多い列をデータから自動判定し、見出し・各評価の加点/非加点/参考・集計を一続きの推奨spineで示す。390pxでは評価軸ごとの全候補ledgerへ転置し、補足は表の下で明示的に展開できる
table · comparison · vendor-selection · data-driven · interactive
LIVE SAMPLE
補足の開閉・表示切替を試せます
ALTERNATIVES
型番 matrix-2x2
項目を2軸のスコアで座標平面に自動配置し、優先順位やポジショニングをデータ駆動で示す。補足は図の下で明示的に展開し、マップ⇄リストを切り替えられる
型番 heatmap
業務領域と評価軸の交点をスコアの濃淡で示し、最高スコアを行・列の座標照準として1件強調する。同じcell identityと座標locatorを保ったまま全体分布から重点課題上位5件へ切り替えられ、補足は図を覆わず下で確認できる
型番 raci-matrix
活動と役割の交点へR/A/C/Iを配置し、最終責任者Aの不在・重複と実行責任者Rの不在をデータから自動検出する。責任分担表、統制課題ledger、mobile activity ledgerが同じ活動・役割順・記号を共有し、診断を元の根拠へ戻して読める
import { useId } from "react";
import type { ComparisonTableProps } from "./schema";
/** 列ごとに述語を満たす値の数を集計する(scoreColumns/booleanRowCounts共通の集計形) */
const tallyColumns = (
rows: { values: (boolean | string)[] }[],
columnCount: number,
predicate: (v: boolean | string) => boolean,
): number[] => {
const counts = Array(columnCount).fill(0);
for (const row of rows) {
row.values.forEach((v, i) => {
if (predicate(v)) counts[i] += 1;
});
}
return counts;
};
/** 列ごとに true の数を集計する(string 値は集計対象外) */
export const scoreColumns = (
rows: { values: (boolean | string)[] }[],
columnCount: number,
): number[] => tallyColumns(rows, columnCount, (v) => v === true);
/**
* 最多スコアの列 index を返す。全列0点なら強調なし(null)、同点は先頭列を採用
* (DESIGN.md: 大胆さは1画面1箇所)。
*/
export const bestColumnIndex = (scores: number[]): number | null => {
const max = Math.max(...scores);
if (max <= 0) return null;
return scores.indexOf(max);
};
/** highlight(ベスト列)専用グラデーション(DESIGN.md: indigo→violet 限定・1件のみ) */
const HIGHLIGHT_GRADIENT = "bg-linear-to-br from-tertiary to-accent-violet";
/**
* 列ごとにboolean値を持つ行数(集計フッターの分母)。string値の行
* (初期費用/月額料金など)は分母から除外する — rows.length をそのまま
* 使うと文字列行が混ざったデータで分母が過大になる。
*/
export const booleanRowCounts = (
rows: { values: (boolean | string)[] }[],
columnCount: number,
): number[] => tallyColumns(rows, columnCount, (v) => typeof v === "boolean");
/** 分母0件は「–」にフォールバックする(0/0という無意味な表記を避ける) */
export const formatTally = (score: number, total: number): string =>
total === 0 ? "–" : `${score}/${total}`;
/** desktop/mobileと評価根拠の対応を共有する安定した座標code */
export const columnCode = (columnIndex: number): string =>
`C${String(columnIndex + 1).padStart(2, "0")}`;
export const criterionCode = (rowIndex: number): string =>
`E${String(rowIndex + 1).padStart(2, "0")}`;
export type EvidenceKind = "supporting" | "counter" | "context";
/** 推奨列の値がboolean集計へどう関与するかを表示用に分類する */
export const evidenceKind = (value: boolean | string): EvidenceKind =>
value === true ? "supporting" : value === false ? "counter" : "context";
/** mobileは推奨候補を先に読み、残りは元の入力順を保つ */
export const mobileColumnOrder = (
columnCount: number,
bestColumn: number | null,
): number[] => {
const inputOrder = Array.from({ length: columnCount }, (_, index) => index);
if (bestColumn === null) return inputOrder;
return [
bestColumn,
...inputOrder.filter((columnIndex) => columnIndex !== bestColumn),
];
};
const evidenceLabel = (kind: EvidenceKind): string =>
kind === "supporting" ? "加点" : kind === "counter" ? "非加点" : "参考";
const ValueMark = ({ value }: { value: boolean | string }) =>
typeof value === "boolean" ? (
value ? (
<span
aria-label="対応"
data-comparison-mark="positive"
className="text-success"
>
○
</span>
) : (
<span
aria-label="非対応"
data-comparison-mark="negative"
className="text-error"
>
×
</span>
)
) : (
<span>{value}</span>
);
export default function ComparisonTable({
title,
subtitle,
columns,
rows,
}: ComparisonTableProps) {
const autoId = useId();
const uid =
autoId.replace(/[^a-zA-Z0-9_-]/g, "") || "comparison-table";
const scores = scoreColumns(rows, columns.length);
const best = bestColumnIndex(scores);
const totals = booleanRowCounts(rows, columns.length);
const notes = rows.filter((row) => row.note);
const mobileOrder = mobileColumnOrder(columns.length, best);
const interactionCss: string = rows
.map((_, rowIndex) => {
const code = criterionCode(rowIndex);
return `
[data-comparison-root="${uid}"]:has([data-comparison-hotspot="${code}"]:is(:hover,:focus)) [data-comparison-criterion-label="${code}"] {
background-color: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface));
box-shadow: inset var(--spacing) 0 0 var(--color-primary);
color: var(--color-primary);
}
[data-comparison-root="${uid}"]:has([data-comparison-hotspot="${code}"]:is(:hover,:focus)) [data-comparison-surface="${code}"] {
box-shadow: inset var(--spacing) 0 0 var(--color-primary);
}`;
})
.join("\n");
return (
<section
data-comparison-root={uid}
className="mx-auto max-w-slide bg-surface p-5 font-sans text-on-surface sm:p-12"
>
<style>{interactionCss}</style>
<header className="mb-7">
<h1 className="text-h1">{title}</h1>
{subtitle && (
<p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
)}
</header>
<div data-comparison-view="desktop" className="hidden sm:block">
<table className="w-full table-fixed border-collapse border border-outline text-body-sm">
<thead>
<tr>
<th className="w-1/3 border border-outline bg-primary p-3 text-left text-on-primary">
<span className="block font-mono text-label opacity-70">
EVIDENCE
</span>
<span className="mt-1 block text-label">評価軸</span>
</th>
{columns.map((col, ci) => (
<th
key={ci}
data-comparison-column={columnCode(ci)}
data-comparison-spine={ci === best ? "header" : undefined}
data-priority={ci === best ? "true" : undefined}
className={`border border-outline p-3 text-center ${
ci === best
? `${HIGHLIGHT_GRADIENT} text-on-tertiary`
: "bg-primary text-on-primary"
}`}
>
<span className="block font-mono text-label opacity-70">
{columnCode(ci)}
</span>
<span className="mt-1 block text-label">{col}</span>
{ci === best && (
<span className="mt-2 block border-t border-on-tertiary/30 pt-2 text-label">
おすすめ · {formatTally(scores[ci], totals[ci])}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr
key={ri}
data-comparison-row={criterionCode(ri)}
className={
ri % 2 === 1 ? "bg-surface-subtle" : "bg-surface"
}
>
<th
scope="row"
data-comparison-criterion-label={criterionCode(ri)}
className="border border-outline p-3 text-left font-normal text-secondary transition-colors duration-150"
>
<span className="block font-mono text-label text-tertiary">
{criterionCode(ri)}
</span>
<span className="mt-1 block">{row.criterion}</span>
</th>
{row.values.map((value, ci) => {
const kind = evidenceKind(value);
const isBest = ci === best;
return (
<td
key={ci}
data-comparison-column={columnCode(ci)}
data-comparison-spine={isBest ? "body" : undefined}
data-comparison-surface={
isBest ? criterionCode(ri) : undefined
}
className={`border border-outline p-3 text-center ${
isBest
? "relative bg-tertiary-container/30 before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-tertiary before:content-['']"
: ""
}`}
>
{isBest ? (
<div
tabIndex={0}
data-comparison-evidence={kind}
data-comparison-hotspot={criterionCode(ri)}
className="flex min-h-12 items-center justify-between gap-2 text-left outline-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary"
>
<span className="text-body">
<ValueMark value={value} />
</span>
<span
className={`text-label ${
kind === "supporting"
? "text-success"
: kind === "counter"
? "text-error"
: "text-secondary"
}`}
>
{evidenceLabel(kind)}
</span>
</div>
) : (
<ValueMark value={value} />
)}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-t-outline">
<th
scope="row"
className="border border-outline bg-surface p-3 text-left text-label text-secondary"
>
○の合計
</th>
{columns.map((_, ci) => (
<td
key={ci}
data-comparison-column={columnCode(ci)}
data-comparison-spine={ci === best ? "footer" : undefined}
className={`border border-outline p-3 text-center text-label ${
ci === best
? "relative bg-tertiary-container/30 text-tertiary before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-tertiary before:content-['']"
: "text-secondary"
}`}
>
{formatTally(scores[ci], totals[ci])}
</td>
))}
</tr>
</tfoot>
</table>
</div>
<div data-comparison-view="mobile" className="sm:hidden">
{best !== null && (
<section
data-comparison-mobile-summary="true"
data-comparison-column={columnCode(best)}
className={`${HIGHLIGHT_GRADIENT} mb-5 p-5 text-on-tertiary`}
>
<p className="font-mono text-label opacity-70">
RECOMMENDATION · {columnCode(best)}
</p>
<div className="mt-2 flex items-end justify-between gap-4">
<h2 className="text-h2">{columns[best]}</h2>
<p className="font-mono text-body">
○ {formatTally(scores[best], totals[best])}
</p>
</div>
</section>
)}
<div className="border-y border-outline">
{rows.map((row, rowIndex) => {
const rowCode = criterionCode(rowIndex);
return (
<article
key={rowIndex}
data-comparison-mobile-criterion={rowCode}
className="border-b border-outline py-4 last:border-b-0"
>
<header className="mb-3 flex items-baseline gap-3">
<span className="font-mono text-label text-tertiary">
{rowCode}
</span>
<h3 className="text-label">{row.criterion}</h3>
</header>
<div className="grid gap-2">
{mobileOrder.map((columnIndex) => {
const value = row.values[columnIndex];
const kind = evidenceKind(value);
const isBest = columnIndex === best;
return (
<div
key={columnIndex}
data-comparison-mobile-option={columnIndex}
data-comparison-column={columnCode(columnIndex)}
data-recommended={isBest ? "true" : undefined}
className={`grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 ${
isBest
? "relative bg-tertiary-container/30 before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-tertiary before:content-['']"
: "bg-surface-subtle"
}`}
>
<div className="min-w-0">
<p className="font-mono text-label text-tertiary">
{columnCode(columnIndex)}
{isBest && (
<span className="ml-2 text-secondary">
推奨列
</span>
)}
</p>
<p className="mt-1 truncate text-body-sm">
{columns[columnIndex]}
</p>
</div>
<div className="text-right">
<p className="text-body">
<ValueMark value={value} />
</p>
{isBest && (
<p
data-comparison-evidence={kind}
className={`mt-1 text-label ${
kind === "supporting"
? "text-success"
: kind === "counter"
? "text-error"
: "text-secondary"
}`}
>
{evidenceLabel(kind)}
</p>
)}
</div>
</div>
);
})}
</div>
</article>
);
})}
</div>
<div className="mt-4 flex items-center justify-between border-b border-outline pb-4 text-label">
<span className="text-secondary">○の合計</span>
<span className="font-mono text-tertiary">
{best === null
? "推奨なし"
: `${columns[best]} · ${formatTally(scores[best], totals[best])}`}
</span>
</div>
</div>
<p className="mt-3 text-label text-secondary">
○の数が最多の列を自動判定。加点・非加点・参考値を同じ推奨spineで確認
</p>
{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((row, index) => (
<article
key={index}
data-note-item="true"
className="rounded-md bg-surface p-4"
>
<h3 className="text-label">{row.criterion}</h3>
<p className="mt-2 text-body-sm">{row.note}</p>
</article>
))}
</div>
</details>
)}
</section>
);
}
import { z } from "zod";
export const comparisonRowSchema = z
.object({
criterion: z.string(),
values: z
.array(z.union([z.boolean(), z.string()]))
.describe("列数分の値。boolean は ○/× マーク、string は自由記述で表示"),
note: z
.string()
.optional()
.describe("表の下の補足欄で明示的に展開される補足"),
})
.strict();
export const schema = z
.object({
title: z.string().describe("スライドタイトル。結論を書くメッセージラインを推奨"),
subtitle: z.string().optional().describe("補足・出典など"),
columns: z.array(z.string()).describe("比較対象(列見出し)"),
rows: z.array(comparisonRowSchema),
})
.strict()
.refine((data) => data.rows.every((row) => row.values.length === data.columns.length), {
message: "各行の values 数は columns 数と一致すること",
path: ["rows"],
})
.refine(
(data) =>
data.rows.every((row) =>
row.values.every((v) => typeof v === typeof row.values[0]),
),
{
message:
"1行の中で boolean と string を混在させないこと(列ごとのboolean行数がずれ、集計フッターとベスト列の強調表示が矛盾するため)",
path: ["rows"],
},
);
export type ComparisonRow = z.infer<typeof comparisonRowSchema>;
export type ComparisonTableProps = z.infer<typeof schema>;
{
"title": "グループウェア3製品比較 — 総合力でB社が優勢",
"subtitle": "○の数が最も多い列を自動判定して強調表示(2026年度 情報システム部門・選定サンプル)",
"columns": ["A社(現行)", "B社", "C社"],
"rows": [
{
"criterion": "初期費用",
"values": ["¥0", "¥50,000", "¥30,000"],
"note": "B社・C社は導入時の初期設定支援費用を含む"
},
{
"criterion": "月額料金(1ユーザ)",
"values": ["¥500", "¥800", "¥600"]
},
{
"criterion": "SSO対応",
"values": [true, true, false]
},
{
"criterion": "モバイルアプリ",
"values": [false, true, true]
},
{
"criterion": "SLA 99.9%以上",
"values": [false, true, false],
"note": "計画メンテナンス時間を除いた月間稼働率の保証水準"
},
{
"criterion": "日本語サポート窓口",
"values": [true, true, false]
}
]
}