diff --git a/packages/app/cypress/e2e/compare-index-matrix.cy.ts b/packages/app/cypress/e2e/compare-index-matrix.cy.ts
new file mode 100644
index 00000000..d7d96194
--- /dev/null
+++ b/packages/app/cypress/e2e/compare-index-matrix.cy.ts
@@ -0,0 +1,86 @@
+describe('Compare index matrix', () => {
+ beforeEach(() => {
+ cy.window().then((win) => {
+ win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ });
+ });
+
+ it('renders one matrix per model section with linked + ghost cells on /compare', () => {
+ cy.visit('/compare');
+ cy.get('[data-testid="compare-pair-matrix"]').should('have.length.greaterThan', 0);
+
+ // Every model section renders exactly one matrix.
+ cy.get('section[id]').then(($sections) => {
+ const withMatrix = $sections.filter(
+ (_, s) => s.querySelector('[data-testid="compare-pair-matrix"]') !== null,
+ );
+ cy.get('[data-testid="compare-pair-matrix"]').should('have.length', withMatrix.length);
+ });
+
+ // Available pairs are real links under /compare/--vs-.
+ cy.get('a[data-testid="compare-matrix-cell"]')
+ .should('have.length.greaterThan', 0)
+ .first()
+ .should('have.attr', 'href')
+ .and('match', /^\/compare\/[a-z0-9-]+-[a-z0-9]+-vs-[a-z0-9]+$/u);
+
+ // Pairs without benchmark data render as non-link ghost cells (the
+ // fixtures' availability is sparse, so ghosts always exist).
+ cy.get('[data-testid="compare-matrix-empty-cell"]').should('have.length.greaterThan', 0);
+
+ // Anchor text (sr-only) carries the pair name for crawlers.
+ cy.get('a[data-testid="compare-matrix-cell"]').first().invoke('text').should('match', /vs/u);
+ });
+
+ it('navigates to the pair page when a cell is clicked', () => {
+ cy.visit('/compare');
+ cy.get('a[data-testid="compare-matrix-cell"]')
+ .first()
+ .then(($a) => {
+ const href = $a.attr('href')!;
+ cy.wrap($a).click();
+ cy.location('pathname').should('eq', href);
+ });
+ });
+
+ it('shows the focused pair in the readout line (same path as hover)', () => {
+ cy.visit('/compare');
+ // React's onMouseEnter is delegated and unreachable via cy.trigger — the
+ // keyboard focus handler updates the same readout state, so exercise that.
+ cy.get('a[data-testid="compare-matrix-cell"]').first().focus();
+ cy.get('a[data-testid="compare-matrix-cell"]')
+ .first()
+ .invoke('attr', 'title')
+ .then((label) => {
+ cy.get('[data-testid="compare-matrix-readout"]').first().should('contain.text', label);
+ });
+ });
+
+ it('renders per-dollar matrices with /compare-per-dollar cell hrefs', () => {
+ cy.visit('/compare-per-dollar');
+ cy.get('[data-testid="compare-pair-matrix"]').should('have.length.greaterThan', 0);
+ cy.get('a[data-testid="compare-matrix-cell"]')
+ .first()
+ .should('have.attr', 'href')
+ .and('match', /^\/compare-per-dollar\//u);
+ });
+
+ it('renders the zh matrices with /zh hrefs and Chinese legend strings', () => {
+ cy.visit('/zh/compare');
+ cy.get('[data-testid="compare-pair-matrix"]').should('have.length.greaterThan', 0);
+ cy.get('a[data-testid="compare-matrix-cell"]')
+ .first()
+ .should('have.attr', 'href')
+ .and('match', /^\/zh\/compare\//u);
+ cy.contains('暂无基准测试数据').should('exist');
+ cy.contains('点击单元格查看对比').should('exist');
+ });
+
+ it('renders the zh per-dollar matrices with /zh/compare-per-dollar hrefs', () => {
+ cy.visit('/zh/compare-per-dollar');
+ cy.get('a[data-testid="compare-matrix-cell"]')
+ .first()
+ .should('have.attr', 'href')
+ .and('match', /^\/zh\/compare-per-dollar\//u);
+ });
+});
diff --git a/packages/app/src/app/compare-per-dollar/page.tsx b/packages/app/src/app/compare-per-dollar/page.tsx
index c33f57b2..a4038325 100644
--- a/packages/app/src/app/compare-per-dollar/page.tsx
+++ b/packages/app/src/app/compare-per-dollar/page.tsx
@@ -1,21 +1,17 @@
import type { Metadata } from 'next';
import Link from 'next/link';
-import {
- HW_REGISTRY,
- SITE_NAME,
- SITE_URL,
- SUPPORTERS_LINE,
-} from '@semianalysisai/inferencex-constants';
+import { SITE_NAME, SITE_URL, SUPPORTERS_LINE } from '@semianalysisai/inferencex-constants';
import { enAlternates } from '@/lib/i18n';
-import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link';
+import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix';
import { JsonLd } from '@/components/json-ld';
import { Card } from '@/components/ui/card';
import { getComparablePairsByModelSlug } from '@/lib/compare-availability';
-import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug';
-import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr';
+import { buildCompareMatrix } from '@/lib/compare-matrix';
+import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug';
+import { formatModelList } from '@/lib/compare-ssr';
export const dynamic = 'force-dynamic';
@@ -38,42 +34,6 @@ export const metadata: Metadata = {
},
};
-interface VendorGroup {
- heading: string;
- description: string;
- pairs: { a: string; b: string; slug: string; label: string }[];
-}
-
-function groupPairsByVendorForModel(
- model: CompareModelSlug,
- comparablePairs: ComparePair[],
-): VendorGroup[] {
- const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs);
- const groups: VendorGroup[] = [];
- if (cross.length > 0) {
- groups.push({
- heading: 'NVIDIA vs AMD',
- description: 'Cross-vendor cost-per-token comparisons across architecture generations.',
- pairs: cross,
- });
- }
- if (nvidia.length > 0) {
- groups.push({
- heading: 'NVIDIA vs NVIDIA',
- description: 'Hopper and Blackwell generation cost-per-token comparisons.',
- pairs: nvidia,
- });
- }
- if (amd.length > 0) {
- groups.push({
- heading: 'AMD vs AMD',
- description: 'CDNA 3 and CDNA 4 generation cost-per-token comparisons.',
- pairs: amd,
- });
- }
- return groups;
-}
-
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
@@ -145,7 +105,6 @@ export default async function ComparePerDollarIndexPage() {
{modelsWithPairs.map((model) => {
const pairs = comparablePairsByModel.get(model.slug) ?? [];
- const groups = groupPairsByVendorForModel(model, pairs);
return (
@@ -156,30 +115,11 @@ export default async function ComparePerDollarIndexPage() {
benchmark data on {model.label}.
- {groups.map((group) => (
-
-
-
{group.heading}
-
{group.description}
-
-
- {group.pairs.map(({ slug, label, a, b }) => {
- const aMeta = HW_REGISTRY[a];
- const bMeta = HW_REGISTRY[b];
- const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`;
- return (
-
- );
- })}
-
-
- ))}
+
);
diff --git a/packages/app/src/app/compare/page.tsx b/packages/app/src/app/compare/page.tsx
index 7c44c8c9..e7100ea5 100644
--- a/packages/app/src/app/compare/page.tsx
+++ b/packages/app/src/app/compare/page.tsx
@@ -1,21 +1,17 @@
import type { Metadata } from 'next';
import Link from 'next/link';
-import {
- HW_REGISTRY,
- SITE_NAME,
- SITE_URL,
- SUPPORTERS_LINE,
-} from '@semianalysisai/inferencex-constants';
+import { SITE_NAME, SITE_URL, SUPPORTERS_LINE } from '@semianalysisai/inferencex-constants';
import { enAlternates } from '@/lib/i18n';
-import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link';
+import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix';
import { JsonLd } from '@/components/json-ld';
import { Card } from '@/components/ui/card';
import { getComparablePairsByModelSlug } from '@/lib/compare-availability';
-import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug';
-import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr';
+import { buildCompareMatrix } from '@/lib/compare-matrix';
+import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug';
+import { formatModelList } from '@/lib/compare-ssr';
export const dynamic = 'force-dynamic';
@@ -38,42 +34,6 @@ export const metadata: Metadata = {
},
};
-interface VendorGroup {
- heading: string;
- description: string;
- pairs: { a: string; b: string; slug: string; label: string }[];
-}
-
-function groupPairsByVendorForModel(
- model: CompareModelSlug,
- comparablePairs: ComparePair[],
-): VendorGroup[] {
- const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs);
- const groups: VendorGroup[] = [];
- if (cross.length > 0) {
- groups.push({
- heading: 'NVIDIA vs AMD',
- description: 'Cross-vendor comparisons across architecture generations.',
- pairs: cross,
- });
- }
- if (nvidia.length > 0) {
- groups.push({
- heading: 'NVIDIA vs NVIDIA',
- description: 'Hopper and Blackwell generation comparisons.',
- pairs: nvidia,
- });
- }
- if (amd.length > 0) {
- groups.push({
- heading: 'AMD vs AMD',
- description: 'CDNA 3 and CDNA 4 generation comparisons.',
- pairs: amd,
- });
- }
- return groups;
-}
-
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
@@ -142,7 +102,6 @@ export default async function CompareIndexPage() {
{modelsWithPairs.map((model) => {
const pairs = comparablePairsByModel.get(model.slug) ?? [];
- const groups = groupPairsByVendorForModel(model, pairs);
return (
@@ -153,30 +112,11 @@ export default async function CompareIndexPage() {
{model.label}.
- {groups.map((group) => (
-
-
-
{group.heading}
-
{group.description}
-
-
- {group.pairs.map(({ slug, label, a, b }) => {
- const aMeta = HW_REGISTRY[a];
- const bMeta = HW_REGISTRY[b];
- const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`;
- return (
-
- );
- })}
-
-
- ))}
+
);
diff --git a/packages/app/src/app/zh/compare-per-dollar/page.tsx b/packages/app/src/app/zh/compare-per-dollar/page.tsx
index a31f6e5b..c4e62887 100644
--- a/packages/app/src/app/zh/compare-per-dollar/page.tsx
+++ b/packages/app/src/app/zh/compare-per-dollar/page.tsx
@@ -1,19 +1,15 @@
import type { Metadata } from 'next';
import Link from 'next/link';
-import {
- HW_REGISTRY,
- SITE_NAME,
- SITE_URL,
- SUPPORTERS_LINE_ZH,
-} from '@semianalysisai/inferencex-constants';
+import { SITE_NAME, SITE_URL, SUPPORTERS_LINE_ZH } from '@semianalysisai/inferencex-constants';
-import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link';
+import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix';
import { JsonLd } from '@/components/json-ld';
import { Card } from '@/components/ui/card';
import { getComparablePairsByModelSlug } from '@/lib/compare-availability';
-import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug';
-import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr';
+import { buildCompareMatrix } from '@/lib/compare-matrix';
+import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug';
+import { formatModelList } from '@/lib/compare-ssr';
import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
@@ -38,42 +34,6 @@ export const metadata: Metadata = {
},
};
-interface VendorGroup {
- heading: string;
- description: string;
- pairs: { a: string; b: string; slug: string; label: string }[];
-}
-
-function groupPairsByVendorForModel(
- model: CompareModelSlug,
- comparablePairs: ComparePair[],
-): VendorGroup[] {
- const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs);
- const groups: VendorGroup[] = [];
- if (cross.length > 0) {
- groups.push({
- heading: 'NVIDIA vs AMD',
- description: '跨厂商的不同架构代际每 token 成本对比。',
- pairs: cross,
- });
- }
- if (nvidia.length > 0) {
- groups.push({
- heading: 'NVIDIA vs NVIDIA',
- description: 'Hopper 与 Blackwell 代际每 token 成本对比。',
- pairs: nvidia,
- });
- }
- if (amd.length > 0) {
- groups.push({
- heading: 'AMD vs AMD',
- description: 'CDNA 3 与 CDNA 4 代际每 token 成本对比。',
- pairs: amd,
- });
- }
- return groups;
-}
-
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
@@ -139,7 +99,6 @@ export default async function ComparePerDollarIndexPageZh() {
{modelsWithPairs.map((model) => {
const pairs = comparablePairsByModel.get(model.slug) ?? [];
- const groups = groupPairsByVendorForModel(model, pairs);
return (
@@ -149,30 +108,11 @@ export default async function ComparePerDollarIndexPageZh() {
{pairs.length} 组 GPU 对比具有 {model.label} 的每 token 成本基准测试数据。
- {groups.map((group) => (
-
-
-
{group.heading}
-
{group.description}
-
-
- {group.pairs.map(({ slug, label, a, b }) => {
- const aMeta = HW_REGISTRY[a];
- const bMeta = HW_REGISTRY[b];
- const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`;
- return (
-
- );
- })}
-
-
- ))}
+
);
diff --git a/packages/app/src/app/zh/compare/page.tsx b/packages/app/src/app/zh/compare/page.tsx
index 55a4f43f..589c0b90 100644
--- a/packages/app/src/app/zh/compare/page.tsx
+++ b/packages/app/src/app/zh/compare/page.tsx
@@ -1,19 +1,15 @@
import type { Metadata } from 'next';
import Link from 'next/link';
-import {
- HW_REGISTRY,
- SITE_NAME,
- SITE_URL,
- SUPPORTERS_LINE_ZH,
-} from '@semianalysisai/inferencex-constants';
+import { SITE_NAME, SITE_URL, SUPPORTERS_LINE_ZH } from '@semianalysisai/inferencex-constants';
-import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link';
+import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix';
import { JsonLd } from '@/components/json-ld';
import { Card } from '@/components/ui/card';
import { getComparablePairsByModelSlug } from '@/lib/compare-availability';
-import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug';
-import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr';
+import { buildCompareMatrix } from '@/lib/compare-matrix';
+import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug';
+import { formatModelList } from '@/lib/compare-ssr';
import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
@@ -38,42 +34,6 @@ export const metadata: Metadata = {
},
};
-interface VendorGroup {
- heading: string;
- description: string;
- pairs: { a: string; b: string; slug: string; label: string }[];
-}
-
-function groupPairsByVendorForModel(
- model: CompareModelSlug,
- comparablePairs: ComparePair[],
-): VendorGroup[] {
- const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs);
- const groups: VendorGroup[] = [];
- if (cross.length > 0) {
- groups.push({
- heading: 'NVIDIA vs AMD',
- description: '跨厂商的不同架构代际对比。',
- pairs: cross,
- });
- }
- if (nvidia.length > 0) {
- groups.push({
- heading: 'NVIDIA vs NVIDIA',
- description: 'Hopper 与 Blackwell 代际对比。',
- pairs: nvidia,
- });
- }
- if (amd.length > 0) {
- groups.push({
- heading: 'AMD vs AMD',
- description: 'CDNA 3 与 CDNA 4 代际对比。',
- pairs: amd,
- });
- }
- return groups;
-}
-
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
@@ -138,7 +98,6 @@ export default async function CompareIndexPageZh() {
{modelsWithPairs.map((model) => {
const pairs = comparablePairsByModel.get(model.slug) ?? [];
- const groups = groupPairsByVendorForModel(model, pairs);
return (
@@ -148,30 +107,11 @@ export default async function CompareIndexPageZh() {
{pairs.length} 组 GPU 对比具有 {model.label} 的基准测试数据。
- {groups.map((group) => (
-
-
-
{group.heading}
-
{group.description}
-
-
- {group.pairs.map(({ slug, label, a, b }) => {
- const aMeta = HW_REGISTRY[a];
- const bMeta = HW_REGISTRY[b];
- const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`;
- return (
-
- );
- })}
-
-
- ))}
+
);
diff --git a/packages/app/src/components/compare/compare-pair-matrix.tsx b/packages/app/src/components/compare/compare-pair-matrix.tsx
new file mode 100644
index 00000000..b7997ae3
--- /dev/null
+++ b/packages/app/src/components/compare/compare-pair-matrix.tsx
@@ -0,0 +1,260 @@
+'use client';
+
+import { useState } from 'react';
+
+import { track } from '@/lib/analytics';
+import type { CompareMatrix } from '@/lib/compare-matrix';
+import { useLocale } from '@/lib/use-locale';
+import { cn } from '@/lib/utils';
+
+const STRINGS = {
+ en: {
+ hint: 'Click a cell to open the comparison.',
+ crossLegend: 'NVIDIA vs AMD',
+ sameLegend: 'Same vendor',
+ emptyLegend: 'No benchmark data yet',
+ matrixAria: (model: string) => `${model} GPU comparison matrix`,
+ cellAria: (model: string, pair: string) => `${model}: ${pair} benchmark comparison`,
+ emptyTitle: (pair: string) => `${pair} — no benchmark data yet`,
+ },
+ zh: {
+ hint: '点击单元格查看对比。',
+ crossLegend: 'NVIDIA vs AMD',
+ sameLegend: '同厂商',
+ emptyLegend: '暂无基准测试数据',
+ matrixAria: (model: string) => `${model} GPU 对比矩阵`,
+ cellAria: (model: string, pair: string) => `${model}:${pair} 基准测试对比`,
+ emptyTitle: (pair: string) => `${pair} — 暂无基准测试数据`,
+ },
+} as const;
+
+/** A row/column cell coordinate plus its display label, tracked for the
+ * hover/focus highlight and readout. */
+interface ActiveCell {
+ row: string;
+ col: string;
+ label: string;
+}
+
+const VENDOR_HEADER_CLASS: Record = {
+ NVIDIA: 'border-emerald-500/60 text-emerald-700 dark:text-emerald-400',
+ AMD: 'border-red-500/60 text-red-700 dark:text-red-400',
+};
+
+const VENDOR_DOT_CLASS: Record = {
+ NVIDIA: 'bg-emerald-500/80',
+ AMD: 'bg-red-500/80',
+};
+
+interface ComparePairMatrixProps {
+ matrix: CompareMatrix;
+ /** Route prefix cells link under, e.g. "/compare" or "/zh/compare-per-dollar". */
+ hrefPrefix: string;
+ /** Model label for accessible names and the analytics payload context. */
+ modelLabel: string;
+}
+
+/**
+ * Lower-triangle GPU×GPU selection matrix for the compare index pages. Each
+ * available pair is a real server-rendered `` (SEO: all pair links stay in
+ * the HTML with the pair name as sr-only anchor text) carrying a visible ↗
+ * glyph so linked cells read as clickable next to the empty ghosts; pairs
+ * without benchmark data render as ghost cells so coverage gaps are visible
+ * at a glance. The vendor blocks make the cross-vendor region a contiguous
+ * rectangle, which gets the brand tint — same-vendor triangles stay neutral.
+ */
+export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePairMatrixProps) {
+ const locale = useLocale();
+ const t = STRINGS[locale];
+ // Hover and keyboard focus are tracked separately so a pointer entering and
+ // leaving a still-focused cell doesn't wipe the focus highlight. Hover takes
+ // precedence while present; when the pointer leaves, a focused cell persists.
+ const [hoveredCell, setHoveredCell] = useState(null);
+ const [focusedCell, setFocusedCell] = useState(null);
+ const active = hoveredCell ?? focusedCell;
+
+ const { gpus, cells } = matrix;
+ // Lower triangle: the first GPU never appears as a row (nothing precedes
+ // it), the last never as a column (nothing follows it).
+ const rows = gpus.slice(1);
+ const cols = gpus.slice(0, -1);
+
+ // Consecutive same-vendor column runs → the colSpan group bars on top.
+ const colGroups: { vendor: string; count: number }[] = [];
+ for (const gpu of cols) {
+ const last = colGroups.at(-1);
+ if (last && last.vendor === gpu.vendor) last.count++;
+ else colGroups.push({ vendor: gpu.vendor, count: 1 });
+ }
+
+ return (
+
+
+
+
+
+ {/* Fixed-layout table: this corner cell sets the row-header
+ column width; the 9 GPU columns share the remaining space. */}
+ |
+ {colGroups.map((group) => (
+
+
+ {group.vendor}
+
+ |
+ ))}
+
+
+ |
+ {cols.map((gpu) => (
+
+
+ {gpu.shortLabel}
+
+ |
+ ))}
+
+
+
+ {rows.map((rowGpu, i) => (
+
+ |
+
+
+
+ {rowGpu.label}
+
+
+ {rowGpu.arch}
+
+
+ |
+ {cols.map((colGpu, jIdx) => {
+ // rows is gpus[1..], so this row's display index is i+1;
+ // cols is gpus[0..n-2], so the column's display index is
+ // jIdx. Cells exist below the diagonal (col before row).
+ if (jIdx > i) {
+ return | ;
+ }
+ const cell = cells[rowGpu.key][colGpu.key];
+ if (!cell.available) {
+ return (
+
+
+ |
+ );
+ }
+ const href = `${hrefPrefix}/${cell.slug}`;
+ return (
+
+ {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
+ return;
+ }
+ e.preventDefault();
+ track('compare_index_pair_clicked', {
+ slug: cell.slug,
+ label: cell.label,
+ });
+ window.location.href = href;
+ }}
+ onMouseEnter={() =>
+ setHoveredCell({ row: rowGpu.key, col: colGpu.key, label: cell.label })
+ }
+ onMouseLeave={() => setHoveredCell(null)}
+ onFocus={() =>
+ setFocusedCell({ row: rowGpu.key, col: colGpu.key, label: cell.label })
+ }
+ onBlur={() => setFocusedCell(null)}
+ className={cn(
+ 'group flex h-8 w-full items-center justify-center rounded-md transition-all duration-150 lg:h-9',
+ 'hover:scale-105 focus-visible:ring-2 focus-visible:ring-brand focus-visible:outline-none',
+ cell.cross
+ ? 'bg-brand/20 ring-1 ring-brand/40 ring-inset hover:bg-brand/45'
+ : 'bg-foreground/15 hover:bg-foreground/30',
+ )}
+ >
+ {/* Visible link affordance — ghost cells stay empty,
+ so the glyph doubles as the clickable/not marker. */}
+
+ ↗
+
+ {cell.label}
+
+ |
+ );
+ })}
+
+ ))}
+
+
+
+
+
+
+ {active ? {active.label} → : t.hint}
+
+
+
+ {t.crossLegend}
+
+
+
+ {t.sameLegend}
+
+
+
+ {t.emptyLegend}
+
+
+
+ );
+}
diff --git a/packages/app/src/lib/compare-matrix.test.ts b/packages/app/src/lib/compare-matrix.test.ts
new file mode 100644
index 00000000..5d207f0c
--- /dev/null
+++ b/packages/app/src/lib/compare-matrix.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest';
+
+import { GPU_KEYS, HW_REGISTRY } from '@semianalysisai/inferencex-constants';
+
+import { buildCompareMatrix, compareMatrixGpuOrder } from './compare-matrix';
+import { canonicalCompareSlug } from './compare-slug';
+
+describe('compareMatrixGpuOrder', () => {
+ it('includes every registry GPU exactly once', () => {
+ const order = compareMatrixGpuOrder();
+ expect(order.map((g) => g.key).toSorted()).toEqual([...GPU_KEYS].toSorted());
+ });
+
+ it('keeps vendors contiguous with NVIDIA first', () => {
+ const vendors = compareMatrixGpuOrder().map((g) => g.vendor);
+ // Once the vendor changes, the earlier vendor must never reappear —
+ // contiguous blocks are what make the cross-vendor region a rectangle.
+ // NVIDIA-first is pinned by VENDOR_BLOCK_ORDER, not derived from registry
+ // sort, so a future low-sort AMD flagship can't silently flip the axis.
+ const blocks = vendors.filter((v, i) => i === 0 || v !== vendors[i - 1]);
+ expect(blocks).toEqual(['NVIDIA', 'AMD']);
+ });
+
+ it('orders each vendor block oldest→newest (registry sort descending)', () => {
+ const order = compareMatrixGpuOrder();
+ for (let i = 1; i < order.length; i++) {
+ if (order[i].vendor !== order[i - 1].vendor) continue;
+ expect(HW_REGISTRY[order[i].key].sort).toBeLessThan(HW_REGISTRY[order[i - 1].key].sort);
+ }
+ // Pin the NVIDIA generational read so a registry sort change is caught.
+ expect(order.slice(0, 2).map((g) => g.key)).toEqual(['h100', 'h200']);
+ });
+
+ it('uses the registry label for rows and the uppercased key for columns', () => {
+ const gb200 = compareMatrixGpuOrder().find((g) => g.key === 'gb200')!;
+ expect(gb200.label).toBe('GB200 NVL72');
+ expect(gb200.shortLabel).toBe('GB200');
+ });
+});
+
+describe('buildCompareMatrix', () => {
+ const PAIRS = [
+ { a: 'h100', b: 'h200' },
+ { a: 'h100', b: 'mi300x' },
+ { a: 'mi300x', b: 'mi325x' },
+ ];
+
+ it('defines cells only for the lower triangle of the display order', () => {
+ const { gpus, cells } = buildCompareMatrix('deepseek-r1', PAIRS);
+ const index = new Map(gpus.map((g, i) => [g.key, i]));
+ for (const [rowKey, row] of Object.entries(cells)) {
+ for (const colKey of Object.keys(row)) {
+ expect(index.get(colKey)!).toBeLessThan(index.get(rowKey)!);
+ }
+ }
+ // Every below-diagonal position is present, even when unavailable.
+ const cellCount = Object.values(cells).reduce((s, r) => s + Object.keys(r).length, 0);
+ expect(cellCount).toBe((gpus.length * (gpus.length - 1)) / 2);
+ });
+
+ it('marks exactly the provided pairs as available', () => {
+ const { cells, availableCount } = buildCompareMatrix('deepseek-r1', PAIRS);
+ expect(availableCount).toBe(PAIRS.length);
+ expect(cells['h200']['h100'].available).toBe(true);
+ expect(cells['mi300x']['h100'].available).toBe(true);
+ expect(cells['mi325x']['mi300x'].available).toBe(true);
+ expect(cells['b200']['h100'].available).toBe(false);
+ });
+
+ it('builds canonical alphabetical slugs regardless of display order', () => {
+ const { cells } = buildCompareMatrix('deepseek-r1', PAIRS);
+ // Display order puts h100 before b200, but the slug sorts alphabetically.
+ expect(cells['b200']['h100'].slug).toBe(canonicalCompareSlug('deepseek-r1', 'b200', 'h100'));
+ expect(cells['b200']['h100'].slug).toBe('deepseek-r1-b200-vs-h100');
+ });
+
+ it('labels cells in canonical alphabetical order to match the destination page', () => {
+ const { cells } = buildCompareMatrix('deepseek-r1', PAIRS);
+ // Display puts h100 (col) before b200 (row), and the label also sorts
+ // alphabetically so it matches the destination page title and the
+ // analytics payload ("deepseek-r1-b200-vs-h100" → "B200 vs H100").
+ expect(cells['b200']['h100'].label).toBe('B200 vs H100');
+ // Display and alphabetical order coincide here, so the label is unchanged.
+ expect(cells['mi355x']['gb200'].label).toBe('GB200 NVL72 vs MI355X');
+ });
+
+ it('flags cross-vendor cells and only those', () => {
+ const { gpus, cells } = buildCompareMatrix('deepseek-r1', PAIRS);
+ const vendorOf = new Map(gpus.map((g) => [g.key, g.vendor]));
+ for (const [rowKey, row] of Object.entries(cells)) {
+ for (const [colKey, cell] of Object.entries(row)) {
+ expect(cell.cross).toBe(vendorOf.get(rowKey) !== vendorOf.get(colKey));
+ }
+ }
+ });
+
+ it('returns an all-ghost matrix for a model with no pairs', () => {
+ const { cells, availableCount } = buildCompareMatrix('glm-5-1', []);
+ expect(availableCount).toBe(0);
+ expect(Object.values(cells).every((r) => Object.values(r).every((c) => !c.available))).toBe(
+ true,
+ );
+ });
+});
diff --git a/packages/app/src/lib/compare-matrix.ts b/packages/app/src/lib/compare-matrix.ts
new file mode 100644
index 00000000..c6095cb2
--- /dev/null
+++ b/packages/app/src/lib/compare-matrix.ts
@@ -0,0 +1,145 @@
+/**
+ * Pure helpers for the GPU-pair selection matrices on the `/compare` and
+ * `/compare-per-dollar` index pages (and their /zh siblings).
+ *
+ * The index used to render one card per comparable pair — ~36 cards per model
+ * across 9+ model sections stopped scaling as a navigation surface. The matrix
+ * replaces the card walls with one lower-triangle GPU×GPU grid per model:
+ * every possible pair is a cell, cells with benchmark data on both sides are
+ * links, cells without data render as ghosts so coverage gaps are visible.
+ *
+ * Axis ordering is derived from HW_REGISTRY rather than hardcoded:
+ *
+ * - Vendors form contiguous blocks (NVIDIA, then AMD) so the three vendor
+ * regions of the old index — NVIDIA×NVIDIA, cross-vendor, AMD×AMD — appear
+ * as contiguous areas of the triangle instead of separate card groups.
+ * Block order is pinned by VENDOR_BLOCK_ORDER (NVIDIA first, the site-wide
+ * convention) rather than derived from registry sort: a future AMD flagship
+ * with a lower sort than GB300 would otherwise silently flip the whole axis
+ * to AMD-first. Any unlisted vendor sorts after the pinned blocks, ordered
+ * by its best (lowest) registry sort value.
+ * - Within a vendor, GPUs sort by registry `sort` DESCENDING. The registry
+ * sorts newest-first for chart legends, so descending here reads
+ * oldest→newest along the axis (H100 → H200 → B200 → B300 → GB200 → GB300),
+ * the natural left-to-right direction for a generational table.
+ */
+import { HW_REGISTRY } from '@semianalysisai/inferencex-constants';
+
+import { canonicalCompareSlug, compareDisplayLabel, type ComparePair } from '@/lib/compare-slug';
+
+export interface CompareMatrixGpu {
+ key: string;
+ /** Full display label for row headers, e.g. "GB200 NVL72". */
+ label: string;
+ /** Compact label for the vertical column headers, e.g. "GB200". */
+ shortLabel: string;
+ arch: string;
+ vendor: string;
+}
+
+export interface CompareMatrixCell {
+ /** Canonical compare slug (alphabetical GPU order), e.g. "deepseek-r1-b200-vs-h100". */
+ slug: string;
+ /** Canonical alphabetical pair label — matches the destination page title
+ * and the pre-matrix `compare_index_pair_clicked` analytics payload, e.g.
+ * "B200 vs H100" (not the display row-vs-column order). */
+ label: string;
+ /** True when both GPUs have benchmark data for the model — cell is a link. */
+ available: boolean;
+ /** True for NVIDIA×AMD pairs — the cross-vendor region gets the brand tint. */
+ cross: boolean;
+}
+
+export interface CompareMatrix {
+ /** Full GPU axis in display order — identical for every model so the nine
+ * matrices line up and coverage is comparable across sections. */
+ gpus: CompareMatrixGpu[];
+ /** cells[rowKey][colKey], defined only for column display-index < row
+ * display-index (lower triangle). */
+ cells: Record>;
+ availableCount: number;
+}
+
+/** Pinned vendor block order for the matrix axes — NVIDIA first per the
+ * site-wide convention. Vendors not listed here (a future third vendor)
+ * sort after the pinned blocks, ordered by their best registry sort, so
+ * new registry entries can never silently reorder the existing axes. */
+const VENDOR_BLOCK_ORDER = ['NVIDIA', 'AMD'];
+
+/** Shared axis order for every model's matrix. See module docblock. */
+export function compareMatrixGpuOrder(): CompareMatrixGpu[] {
+ const entries = Object.entries(HW_REGISTRY).map(([key, meta]) => ({
+ key,
+ label: meta.label,
+ shortLabel: key.toUpperCase(),
+ arch: meta.arch,
+ vendor: meta.vendor,
+ sort: meta.sort,
+ }));
+
+ const vendorRank = new Map();
+ for (const e of entries) {
+ const best = vendorRank.get(e.vendor);
+ if (best === undefined || e.sort < best) vendorRank.set(e.vendor, e.sort);
+ }
+
+ entries.sort((x, y) => {
+ if (x.vendor !== y.vendor) {
+ const ix = VENDOR_BLOCK_ORDER.indexOf(x.vendor);
+ const iy = VENDOR_BLOCK_ORDER.indexOf(y.vendor);
+ // Both pinned → pinned order. One pinned → it sorts first. Neither pinned
+ // → best registry sort, so a future third vendor still orders
+ // deterministically without disturbing the pinned NVIDIA→AMD blocks.
+ if (ix !== -1 || iy !== -1) {
+ if (ix === -1) return 1;
+ if (iy === -1) return -1;
+ return ix - iy;
+ }
+ return (vendorRank.get(x.vendor) ?? 0) - (vendorRank.get(y.vendor) ?? 0);
+ }
+ return y.sort - x.sort;
+ });
+
+ return entries.map(({ key, label, shortLabel, arch, vendor }) => ({
+ key,
+ label,
+ shortLabel,
+ arch,
+ vendor,
+ }));
+}
+
+/** Build one model's matrix from the pairs that actually have benchmark data
+ * on both sides (`getComparablePairsByModelSlug()` output — canonical
+ * alphabetical a < b). Fully serializable: built in the server page, rendered
+ * by the ComparePairMatrix client component. */
+export function buildCompareMatrix(
+ modelSlug: string,
+ availablePairs: ComparePair[],
+): CompareMatrix {
+ const gpus = compareMatrixGpuOrder();
+ const availableKeys = new Set(availablePairs.map((p) => `${p.a}__${p.b}`));
+
+ const cells: Record> = {};
+ let availableCount = 0;
+
+ for (let j = 1; j < gpus.length; j++) {
+ const row: Record = {};
+ for (let i = 0; i < j; i++) {
+ const col = gpus[i];
+ const rowGpu = gpus[j];
+ const [first, second] = [col.key, rowGpu.key].toSorted();
+ const available = availableKeys.has(`${first}__${second}`);
+ if (available) availableCount++;
+ row[col.key] = {
+ slug: canonicalCompareSlug(modelSlug, col.key, rowGpu.key),
+ label: compareDisplayLabel(first, second),
+ available,
+ cross: col.vendor !== rowGpu.vendor,
+ };
+ }
+ cells[gpus[j].key] = row;
+ }
+
+ return { gpus, cells, availableCount };
+}
diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts
index 592a8238..1199a526 100644
--- a/packages/app/src/lib/compare-ssr.ts
+++ b/packages/app/src/lib/compare-ssr.ts
@@ -31,9 +31,7 @@ import { cachedQuery } from '@/lib/api-cache';
import { rowToAggDataEntry } from '@/lib/benchmark-transform';
import { getHardwareKey } from '@/lib/chart-utils';
import {
- canonicalCompareSlug,
compareDisplayLabel,
- type ComparePair,
type CompareModelSlug,
compareModelDisplayLabel,
compareModelSeoName,
@@ -887,48 +885,6 @@ export function formatModelList(models: CompareModelSlug[]): string {
return `${labels.slice(0, -1).join(', ')}, and ${labels.at(-1)}`;
}
-export interface VendorBucketEntry {
- a: string;
- b: string;
- slug: string;
- label: string;
-}
-
-export interface VendorBuckets {
- /** Cross-vendor pairs (NVIDIA × AMD). */
- cross: VendorBucketEntry[];
- /** Both sides NVIDIA. */
- nvidia: VendorBucketEntry[];
- /** Both sides AMD. */
- amd: VendorBucketEntry[];
-}
-
-/** Split (a, b) GPU pairs into vendor buckets for the index grid. The caller
- * wraps these entries with its own group headings / descriptions / route
- * prefix — keeps the sorting + bucketing + slug-building in one place so the
- * two index pages can't drift on those mechanics. */
-export function bucketComparePairsByVendor(modelSlug: string, pairs: ComparePair[]): VendorBuckets {
- const nvidia: VendorBucketEntry[] = [];
- const amd: VendorBucketEntry[] = [];
- const cross: VendorBucketEntry[] = [];
-
- for (const { a, b } of pairs) {
- const entry: VendorBucketEntry = {
- a,
- b,
- slug: canonicalCompareSlug(modelSlug, a, b),
- label: compareDisplayLabel(a, b),
- };
- const vA = HW_REGISTRY[a]?.vendor;
- const vB = HW_REGISTRY[b]?.vendor;
- if (vA === 'NVIDIA' && vB === 'NVIDIA') nvidia.push(entry);
- else if (vA === 'AMD' && vB === 'AMD') amd.push(entry);
- else cross.push(entry);
- }
-
- return { cross, nvidia, amd };
-}
-
/** Breadcrumb trail for a compare slug page. Emitted alongside the main
* Dataset/ItemList JSON-LD so Google can render the Home → Compare → A vs B
* trail in search results. Variant chooses /compare vs /compare-per-dollar. */