From 6424aa604f610389a9f0daedc95e49a860b860db Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:50:39 +0800 Subject: [PATCH 1/6] feat(compare): replace index card walls with GPU pair selection matrices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /compare and /compare-per-dollar indexes rendered ~258 pair cards across 9+ model sections, which no longer scales as a navigation surface. Each model section now renders one upper-triangle GPU×GPU matrix instead: pairs with benchmark data are server-rendered links (brand tint for cross-vendor, neutral fill for same-vendor), pairs without data render as dashed ghost cells so coverage gaps are visible at a glance. - Axis order derives from HW_REGISTRY: contiguous vendor blocks (NVIDIA then AMD), oldest-to-newest within a vendor, so the cross-vendor region forms a rectangle replacing the old three vendor card groups - Hover/focus highlights the row and column headers and shows the pair label in a readout line; cells carry full accessible names, native tooltips, and sr-only pair-name anchor text for crawlers - compare_index_pair_clicked fires with the same {slug, label} payload as the old cards - Mobile: horizontal scroll with a sticky GPU-label column - /zh index pages are intentionally unchanged in this PR --- .../cypress/e2e/compare-index-matrix.cy.ts | 67 ++++++ .../app/src/app/compare-per-dollar/page.tsx | 80 +------ packages/app/src/app/compare/page.tsx | 80 +------ .../compare/compare-pair-matrix.tsx | 218 ++++++++++++++++++ packages/app/src/lib/compare-matrix.test.ts | 98 ++++++++ packages/app/src/lib/compare-matrix.ts | 124 ++++++++++ 6 files changed, 527 insertions(+), 140 deletions(-) create mode 100644 packages/app/cypress/e2e/compare-index-matrix.cy.ts create mode 100644 packages/app/src/components/compare/compare-pair-matrix.tsx create mode 100644 packages/app/src/lib/compare-matrix.test.ts create mode 100644 packages/app/src/lib/compare-matrix.ts 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..ba9e167b --- /dev/null +++ b/packages/app/cypress/e2e/compare-index-matrix.cy.ts @@ -0,0 +1,67 @@ +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); + }); +}); 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/components/compare/compare-pair-matrix.tsx b/packages/app/src/components/compare/compare-pair-matrix.tsx new file mode 100644 index 00000000..3b0105aa --- /dev/null +++ b/packages/app/src/components/compare/compare-pair-matrix.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useState } from 'react'; + +import { track } from '@/lib/analytics'; +import type { CompareMatrix } from '@/lib/compare-matrix'; +import { cn } from '@/lib/utils'; + +const STRINGS = { + 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`, +} as const; + +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; +} + +/** + * Upper-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); 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 t = STRINGS; + const [hovered, setHovered] = useState<{ row: string; col: string; label: string } | null>(null); + + const { gpus, cells } = matrix; + const rows = gpus.slice(0, -1); + const cols = gpus.slice(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 ( +
+
+ + + + + ))} + + + + ))} + + + + {rows.map((rowGpu, i) => ( + + + {cols.map((colGpu, jIdx) => { + // cols is gpus[1..]; display index of this column is jIdx+1. + if (jIdx + 1 <= i) { + return + ); + } + const href = `${hrefPrefix}/${cell.slug}`; + return ( + + ); + })} + + ))} + +
+
+ {group.vendor} +
+
+
+ + {gpu.shortLabel} + +
+
+
+ + + + {rowGpu.arch} + +
+
+ + { + 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={() => + setHovered({ row: rowGpu.key, col: colGpu.key, label: cell.label }) + } + onMouseLeave={() => setHovered(null)} + onFocus={() => + setHovered({ row: rowGpu.key, col: colGpu.key, label: cell.label }) + } + onBlur={() => setHovered(null)} + className={cn( + 'block size-8 rounded-md transition-all duration-150 lg:size-9', + 'hover:scale-110 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', + )} + > + {cell.label} + +
+
+ +
+ + {hovered ? {hovered.label} → : t.hint} + + + + + + + +
+
+ ); +} 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..055fda69 --- /dev/null +++ b/packages/app/src/lib/compare-matrix.test.ts @@ -0,0 +1,98 @@ +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. + 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 upper 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)!).toBeGreaterThan(index.get(rowKey)!); + } + } + // Every above-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['h100']['h200'].available).toBe(true); + expect(cells['h100']['mi300x'].available).toBe(true); + expect(cells['mi300x']['mi325x'].available).toBe(true); + expect(cells['h100']['b200'].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['h100']['b200'].slug).toBe(canonicalCompareSlug('deepseek-r1', 'b200', 'h100')); + expect(cells['h100']['b200'].slug).toBe('deepseek-r1-b200-vs-h100'); + }); + + it('labels cells in display (row vs column) order', () => { + const { cells } = buildCompareMatrix('deepseek-r1', PAIRS); + expect(cells['h100']['b200'].label).toBe('H100 vs B200'); + expect(cells['gb200']['mi355x'].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..5d696c2c --- /dev/null +++ b/packages/app/src/lib/compare-matrix.ts @@ -0,0 +1,124 @@ +/** + * Pure helpers for the GPU-pair selection matrices on the `/compare` and + * `/compare-per-dollar` index pages. + * + * 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 upper-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. + * Vendor blocks are ordered by each vendor's best (lowest) registry sort + * value, which matches the NVIDIA-first ordering used across the site. + * - 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; + /** Display-ordered pair label matching the cell position, e.g. "H100 vs B200". */ + 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 (upper triangle). */ + cells: Record>; + availableCount: number; +} + +/** 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) { + 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 i = 0; i < gpus.length; i++) { + const row: Record = {}; + for (let j = i + 1; j < gpus.length; j++) { + const a = gpus[i]; + const b = gpus[j]; + const [first, second] = [a.key, b.key].toSorted(); + const available = availableKeys.has(`${first}__${second}`); + if (available) availableCount++; + row[b.key] = { + slug: canonicalCompareSlug(modelSlug, a.key, b.key), + label: compareDisplayLabel(a.key, b.key), + available, + cross: a.vendor !== b.vendor, + }; + } + cells[gpus[i].key] = row; + } + + return { gpus, cells, availableCount }; +} From d24e7d6f6414ca7e582efc9ff1396b6922244947 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:22:50 +0800 Subject: [PATCH 2/6] fix(compare): port index matrices to /zh and preserve focus highlight Bring the /zh/compare and /zh/compare-per-dollar index pages to parity with their English siblings: they now render the ComparePairMatrix instead of the old vendor-group card walls. The matrix component gains an { en, zh } STRINGS dict resolved via useLocale(); English strings are byte-identical. Drops the now-unused per-page VendorGroup helpers and the bucketComparePairsByVendor helper (plus its interfaces and orphaned imports) from compare-ssr.ts. Also fixes a hover/focus bug in ComparePairMatrix: hover and keyboard focus shared one state, so a pointer entering and leaving a focused cell cleared the focus highlight. Split into hoveredCell/focusedCell with active = hoveredCell ?? focusedCell so hover takes precedence while present and a focused cell's highlight persists after the pointer leaves. Adds zh coverage to the compare-index-matrix e2e spec. --- .../cypress/e2e/compare-index-matrix.cy.ts | 19 +++++ .../src/app/zh/compare-per-dollar/page.tsx | 80 +++---------------- packages/app/src/app/zh/compare/page.tsx | 80 +++---------------- .../compare/compare-pair-matrix.tsx | 58 ++++++++++---- packages/app/src/lib/compare-matrix.ts | 2 +- packages/app/src/lib/compare-ssr.ts | 44 ---------- 6 files changed, 82 insertions(+), 201 deletions(-) diff --git a/packages/app/cypress/e2e/compare-index-matrix.cy.ts b/packages/app/cypress/e2e/compare-index-matrix.cy.ts index ba9e167b..d7d96194 100644 --- a/packages/app/cypress/e2e/compare-index-matrix.cy.ts +++ b/packages/app/cypress/e2e/compare-index-matrix.cy.ts @@ -64,4 +64,23 @@ describe('Compare index matrix', () => { .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/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 index 3b0105aa..428bfb5c 100644 --- a/packages/app/src/components/compare/compare-pair-matrix.tsx +++ b/packages/app/src/components/compare/compare-pair-matrix.tsx @@ -4,18 +4,38 @@ 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 = { - 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`, + 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', @@ -43,8 +63,14 @@ interface ComparePairMatrixProps { * gets the brand tint — same-vendor triangles stay neutral. */ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePairMatrixProps) { - const t = STRINGS; - const [hovered, setHovered] = useState<{ row: string; col: string; label: string } | null>(null); + 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; const rows = gpus.slice(0, -1); @@ -91,7 +117,7 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai title={`${gpu.label} (${gpu.arch})`} className={cn( 'rotate-180 text-[10px] font-medium tracking-wide whitespace-nowrap transition-colors [writing-mode:vertical-rl]', - hovered?.col === gpu.key ? 'text-foreground' : 'text-muted-foreground', + active?.col === gpu.key ? 'text-foreground' : 'text-muted-foreground', )} > {gpu.shortLabel} @@ -108,7 +134,7 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai
@@ -164,13 +190,13 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai window.location.href = href; }} onMouseEnter={() => - setHovered({ row: rowGpu.key, col: colGpu.key, label: cell.label }) + setHoveredCell({ row: rowGpu.key, col: colGpu.key, label: cell.label }) } - onMouseLeave={() => setHovered(null)} + onMouseLeave={() => setHoveredCell(null)} onFocus={() => - setHovered({ row: rowGpu.key, col: colGpu.key, label: cell.label }) + setFocusedCell({ row: rowGpu.key, col: colGpu.key, label: cell.label }) } - onBlur={() => setHovered(null)} + onBlur={() => setFocusedCell(null)} className={cn( 'block size-8 rounded-md transition-all duration-150 lg:size-9', 'hover:scale-110 focus-visible:ring-2 focus-visible:ring-brand focus-visible:outline-none', @@ -192,7 +218,7 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai
- {hovered ? {hovered.label} → : t.hint} + {active ? {active.label} → : t.hint} Date: Mon, 20 Jul 2026 20:31:14 +0800 Subject: [PATCH 3/6] fix(compare): label matrix cells in canonical pair order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildCompareMatrix built cell.label via compareDisplayLabel in display (row vs column) order, so the H100×B200 cell read "H100 vs B200" while its destination page /compare/-b200-vs-h100 titles the pair "B200 vs H100" — and the pre-matrix index cards sent that same alphabetical label in the compare_index_pair_clicked analytics payload. Tooltip, sr-only anchor text, readout, and analytics disagreed with the destination for every pair whose display order differed from A-Z. Label from the already-sorted [first, second] instead so it matches the canonical slug, the destination title, and the analytics payload. Update the CompareMatrixCell.label doc and the unit test accordingly. --- packages/app/src/lib/compare-matrix.test.ts | 8 ++++++-- packages/app/src/lib/compare-matrix.ts | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/app/src/lib/compare-matrix.test.ts b/packages/app/src/lib/compare-matrix.test.ts index 055fda69..f926bb7f 100644 --- a/packages/app/src/lib/compare-matrix.test.ts +++ b/packages/app/src/lib/compare-matrix.test.ts @@ -72,9 +72,13 @@ describe('buildCompareMatrix', () => { expect(cells['h100']['b200'].slug).toBe('deepseek-r1-b200-vs-h100'); }); - it('labels cells in display (row vs column) order', () => { + it('labels cells in canonical alphabetical order to match the destination page', () => { const { cells } = buildCompareMatrix('deepseek-r1', PAIRS); - expect(cells['h100']['b200'].label).toBe('H100 vs B200'); + // Display order puts h100 (row) before b200 (col), but the label sorts + // alphabetically so it matches the destination page title and the + // analytics payload ("deepseek-r1-b200-vs-h100" → "B200 vs H100"). + expect(cells['h100']['b200'].label).toBe('B200 vs H100'); + // Display and alphabetical order coincide here, so the label is unchanged. expect(cells['gb200']['mi355x'].label).toBe('GB200 NVL72 vs MI355X'); }); diff --git a/packages/app/src/lib/compare-matrix.ts b/packages/app/src/lib/compare-matrix.ts index 8ff4b8b6..d94eff2b 100644 --- a/packages/app/src/lib/compare-matrix.ts +++ b/packages/app/src/lib/compare-matrix.ts @@ -37,7 +37,9 @@ export interface CompareMatrixGpu { export interface CompareMatrixCell { /** Canonical compare slug (alphabetical GPU order), e.g. "deepseek-r1-b200-vs-h100". */ slug: string; - /** Display-ordered pair label matching the cell position, e.g. "H100 vs B200". */ + /** 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; @@ -112,7 +114,7 @@ export function buildCompareMatrix( if (available) availableCount++; row[b.key] = { slug: canonicalCompareSlug(modelSlug, a.key, b.key), - label: compareDisplayLabel(a.key, b.key), + label: compareDisplayLabel(first, second), available, cross: a.vendor !== b.vendor, }; From 1acdfd8902f1a5a171147650d155ae86362f7ab5 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:38:08 +0800 Subject: [PATCH 4/6] fix(compare): pin matrix vendor block order instead of deriving it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compareMatrixGpuOrder ranked vendor blocks by each vendor's lowest registry sort value. That yields NVIDIA-first today, but the registry interleaves vendors by generation, so a future AMD flagship whose sort dips below GB300's would silently flip the whole axis to AMD-first and move the cross-vendor region — contradicting the docblock's stated NVIDIA-first design. Pin the order with a VENDOR_BLOCK_ORDER constant (NVIDIA, then AMD): listed vendors sort by their pinned index, a listed vendor always precedes an unlisted one, and two unlisted vendors fall back to the existing best-registry-sort comparison so a future third vendor still orders deterministically. Within-vendor ordering is unchanged. Docblock and the contiguity test comment updated to reflect the pinned invariant. --- packages/app/src/lib/compare-matrix.test.ts | 2 ++ packages/app/src/lib/compare-matrix.ts | 23 +++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/app/src/lib/compare-matrix.test.ts b/packages/app/src/lib/compare-matrix.test.ts index f926bb7f..b897415e 100644 --- a/packages/app/src/lib/compare-matrix.test.ts +++ b/packages/app/src/lib/compare-matrix.test.ts @@ -15,6 +15,8 @@ describe('compareMatrixGpuOrder', () => { 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']); }); diff --git a/packages/app/src/lib/compare-matrix.ts b/packages/app/src/lib/compare-matrix.ts index d94eff2b..06ad072d 100644 --- a/packages/app/src/lib/compare-matrix.ts +++ b/packages/app/src/lib/compare-matrix.ts @@ -13,8 +13,11 @@ * - 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. - * Vendor blocks are ordered by each vendor's best (lowest) registry sort - * value, which matches the NVIDIA-first ordering used across the site. + * 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), @@ -57,6 +60,12 @@ export interface CompareMatrix { 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]) => ({ @@ -76,6 +85,16 @@ export function compareMatrixGpuOrder(): CompareMatrixGpu[] { 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; From 822b135f1e6fae2fa2304b17ae5c33f33f01d32c Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:54:54 +0800 Subject: [PATCH 5/6] fix(compare): stretch index matrices to full card width on desktop The matrix table previously sized to its content (fixed 32/36px square cells), leaving roughly half of the card empty on desktop viewports. Switch to a fixed-layout full-width table: the row-header column keeps a set width and the nine GPU columns share the remaining space, so cells grow into wide rectangles that fill the card at any width. Column headers become horizontal (they no longer need to fit narrow squares), and the 600px table min-width preserves the horizontal-scroll + sticky row-label behavior on mobile. --- .../compare/compare-pair-matrix.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/compare/compare-pair-matrix.tsx b/packages/app/src/components/compare/compare-pair-matrix.tsx index 428bfb5c..4ed3b842 100644 --- a/packages/app/src/components/compare/compare-pair-matrix.tsx +++ b/packages/app/src/components/compare/compare-pair-matrix.tsx @@ -90,11 +90,13 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai - ))} @@ -165,7 +165,7 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai data-testid="compare-matrix-empty-cell" title={t.emptyTitle(cell.label)} aria-hidden="true" - className="size-8 rounded-md border border-dashed border-border/50 lg:size-9" + className="h-8 w-full rounded-md border border-dashed border-border/50 lg:h-9" /> ); @@ -198,8 +198,8 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai } onBlur={() => setFocusedCell(null)} className={cn( - 'block size-8 rounded-md transition-all duration-150 lg:size-9', - 'hover:scale-110 focus-visible:ring-2 focus-visible:ring-brand focus-visible:outline-none', + 'block h-8 w-full 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', From 2c49d6bbb3bc4fbc20d6455f973e0960bd4edf90 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:20:12 +0800 Subject: [PATCH 6/6] feat(compare): flip index matrices to lower triangle and mark linked cells Flip the GPU-pair selection matrices from upper to lower triangle: rows now run H200 -> MI355X and columns H100 -> MI325X, with cells below the diagonal (cells[rowKey][colKey], column display-index < row display-index) so the storage matches the rendered grid. Slugs, labels, and cross-vendor flags are unchanged (still canonical alphabetical order). Also add a persistent clickability affordance: every linked cell centers a visible north-east arrow glyph (brand-tinted on cross-vendor cells, muted on same-vendor, brightening on hover) while ghost cells stay empty, so linked vs no-data cells are distinguishable without hovering. --- .../compare/compare-pair-matrix.tsx | 36 +++++++++++++------ packages/app/src/lib/compare-matrix.test.ts | 24 ++++++------- packages/app/src/lib/compare-matrix.ts | 24 ++++++------- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/packages/app/src/components/compare/compare-pair-matrix.tsx b/packages/app/src/components/compare/compare-pair-matrix.tsx index 4ed3b842..b7997ae3 100644 --- a/packages/app/src/components/compare/compare-pair-matrix.tsx +++ b/packages/app/src/components/compare/compare-pair-matrix.tsx @@ -55,12 +55,13 @@ interface ComparePairMatrixProps { } /** - * Upper-triangle GPU×GPU selection matrix for the compare index pages. Each + * 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); 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. + * 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(); @@ -73,8 +74,10 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai const active = hoveredCell ?? focusedCell; const { gpus, cells } = matrix; - const rows = gpus.slice(0, -1); - const cols = gpus.slice(1); + // 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 }[] = []; @@ -153,8 +156,10 @@ export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePai {cols.map((colGpu, jIdx) => { - // cols is gpus[1..]; display index of this column is jIdx+1. - if (jIdx + 1 <= i) { + // 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 diff --git a/packages/app/src/lib/compare-matrix.test.ts b/packages/app/src/lib/compare-matrix.test.ts index b897415e..5d207f0c 100644 --- a/packages/app/src/lib/compare-matrix.test.ts +++ b/packages/app/src/lib/compare-matrix.test.ts @@ -45,15 +45,15 @@ describe('buildCompareMatrix', () => { { a: 'mi300x', b: 'mi325x' }, ]; - it('defines cells only for the upper triangle of the display order', () => { + 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)!).toBeGreaterThan(index.get(rowKey)!); + expect(index.get(colKey)!).toBeLessThan(index.get(rowKey)!); } } - // Every above-diagonal position is present, even when unavailable. + // 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); }); @@ -61,27 +61,27 @@ describe('buildCompareMatrix', () => { it('marks exactly the provided pairs as available', () => { const { cells, availableCount } = buildCompareMatrix('deepseek-r1', PAIRS); expect(availableCount).toBe(PAIRS.length); - expect(cells['h100']['h200'].available).toBe(true); - expect(cells['h100']['mi300x'].available).toBe(true); - expect(cells['mi300x']['mi325x'].available).toBe(true); - expect(cells['h100']['b200'].available).toBe(false); + 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['h100']['b200'].slug).toBe(canonicalCompareSlug('deepseek-r1', 'b200', 'h100')); - expect(cells['h100']['b200'].slug).toBe('deepseek-r1-b200-vs-h100'); + 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 order puts h100 (row) before b200 (col), but the label sorts + // 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['h100']['b200'].label).toBe('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['gb200']['mi355x'].label).toBe('GB200 NVL72 vs MI355X'); + expect(cells['mi355x']['gb200'].label).toBe('GB200 NVL72 vs MI355X'); }); it('flags cross-vendor cells and only those', () => { diff --git a/packages/app/src/lib/compare-matrix.ts b/packages/app/src/lib/compare-matrix.ts index 06ad072d..c6095cb2 100644 --- a/packages/app/src/lib/compare-matrix.ts +++ b/packages/app/src/lib/compare-matrix.ts @@ -4,7 +4,7 @@ * * 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 upper-triangle GPU×GPU grid per model: + * 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. * @@ -54,8 +54,8 @@ 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 (upper triangle). */ + /** cells[rowKey][colKey], defined only for column display-index < row + * display-index (lower triangle). */ cells: Record>; availableCount: number; } @@ -123,22 +123,22 @@ export function buildCompareMatrix( const cells: Record> = {}; let availableCount = 0; - for (let i = 0; i < gpus.length; i++) { + for (let j = 1; j < gpus.length; j++) { const row: Record = {}; - for (let j = i + 1; j < gpus.length; j++) { - const a = gpus[i]; - const b = gpus[j]; - const [first, second] = [a.key, b.key].toSorted(); + 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[b.key] = { - slug: canonicalCompareSlug(modelSlug, a.key, b.key), + row[col.key] = { + slug: canonicalCompareSlug(modelSlug, col.key, rowGpu.key), label: compareDisplayLabel(first, second), available, - cross: a.vendor !== b.vendor, + cross: col.vendor !== rowGpu.vendor, }; } - cells[gpus[i].key] = row; + cells[gpus[j].key] = row; } return { gpus, cells, availableCount };
-
- - {gpu.shortLabel} - -
+
+ + {gpu.shortLabel} +